cocina_display 0.2.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: ca2e2a44615201a7709f9a7b4d7e0792aaea7f14344dc93b3bf800acdef83e91
4
- data.tar.gz: 8dfd7f06a65930d2d639083c1235a7a9e41d8bcf59ac12f60bd4b2b87118d784
3
+ metadata.gz: 0724f307088c9cdd9348af463e3c659811eef65d0f1f04e094b4c664a36cfed1
4
+ data.tar.gz: 4b49472d07a085455ca72204e6ebde3cfc27c634f8ba9cd5b2c2a47d62d19aad
5
5
  SHA512:
6
- metadata.gz: 11e6b9f94eaa777d1069deb972e046cda240e815524a2d938a8df97f75556393a10706c73dcaa9172a2c2a3cb90a459ed0ce8ca3c9d04f5f025242629b10f809
7
- data.tar.gz: be9642a9205aca7b1aac2c73cb6250dbc1def18cba5fa88b3afdb4839186f6832ca80404f04ca47680fe181738276317f27b9c751c527b6ded4b6d637d1d1254
6
+ metadata.gz: edc1e8977792d21fd6fef292f486c11eab4011dc63c854af1217aced6930ea55538e158abc261491c08b1f8492c374c52102b44931849b2350e1dacf54310d41
7
+ data.tar.gz: 664691764d4a4ff7da024af64ce9dd2d0841d8c014ee9439c3ebebe4b3a4c2e585e7f608f2ff943a04586dfaad8fc4ec7cfa714fade929ca9b0278ec740bff8b
data/README.md CHANGED
@@ -28,25 +28,19 @@ To start, you need some Cocina in JSON form.
28
28
 
29
29
  You can download some directly from PURL by visiting an object's PURL URL and appending `.json` to the end, like `https://purl.stanford.edu/bb112zx3193.json`. Some examples are available in the `spec/fixtures` directory.
30
30
 
31
- You can also use the built-in `HTTP` library or `faraday` gem to fetch the record for you, e.g.:
31
+ There is also a helper method to fetch the Cocina JSON for a given DRUID and immediately parse it into a `CocinaRecord` object:
32
32
 
33
33
  ```ruby
34
- require 'http'
35
- cocina_json = HTTP.get('https://purl.stanford.edu/bb112zx3193.json').to_s
34
+ > record = CocinaDisplay::CocinaRecord.fetch('bb112zx3193')
35
+ => #<CocinaDisplay::CocinaRecord:0x00007f8c8c0b5c80
36
36
  ```
37
37
 
38
38
  ### Working with objects
39
39
 
40
- Once you have the JSON, you can initialize a `CocinaRecord` object and start working with it. The `CocinaRecord` class provides some methods to access common fields, as well as an underlying hash representation parsed from the JSON.
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
- > require 'cocina_display/cocina_record'
44
- => true
45
- > record = CocinaDisplay::CocinaRecord.new(cocina_json)
46
- =>
47
- #<CocinaDisplay::CocinaRecord:0x000000012d11b600
48
- ...
49
- > record.title
43
+ > record.main_title
50
44
  => "Bugatti Type 51A. Road & Track Salon January 1957"
51
45
  > record.content_type
52
46
  => "image"
@@ -2,16 +2,34 @@
2
2
 
3
3
  require "janeway"
4
4
  require "json"
5
- require "uri"
5
+ require "net/http"
6
6
  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"
10
+ require_relative "concerns/events"
11
+ require_relative "concerns/contributors"
12
+ require_relative "concerns/identifiers"
13
+ require_relative "concerns/titles"
11
14
 
12
15
  module CocinaDisplay
13
16
  # Public Cocina metadata for an SDR object, as fetched from PURL.
14
17
  class CocinaRecord
18
+ include CocinaDisplay::Concerns::Events
19
+ include CocinaDisplay::Concerns::Contributors
20
+ include CocinaDisplay::Concerns::Identifiers
21
+ include CocinaDisplay::Concerns::Titles
22
+
23
+ # Fetch a public Cocina document from PURL and create a CocinaRecord.
24
+ # @note This is intended to be used in development or testing only.
25
+ # @param druid [String] The bare DRUID of the object to fetch.
26
+ # @return [CocinaDisplay::CocinaRecord]
27
+ # :nocov:
28
+ def self.fetch(druid)
29
+ new(Net::HTTP.get(URI("https://purl.stanford.edu/#{druid}.json")))
30
+ end
31
+ # :nocov:
32
+
15
33
  # The parsed Cocina document.
16
34
  # @return [Hash]
17
35
  attr_reader :cocina_doc
@@ -32,60 +50,6 @@ module CocinaDisplay
32
50
  Janeway.enum_for(path_expression, cocina_doc)
33
51
  end
34
52
 
35
- # The DRUID for the object, with the +druid:+ prefix.
36
- # @return [String]
37
- # @example
38
- # record.druid #=> "druid:bb099mt5053"
39
- def druid
40
- cocina_doc["externalIdentifier"]
41
- end
42
-
43
- # The DRUID for the object, without the +druid:+ prefix.
44
- # @return [String]
45
- # @example
46
- # record.bare_druid #=> "bb099mt5053"
47
- def bare_druid
48
- druid.delete_prefix("druid:")
49
- end
50
-
51
- # The DOI for the object, if there is one – just the identifier part.
52
- # @return [String, nil]
53
- # @example
54
- # record.doi #=> "10.25740/ppax-bf07"
55
- def doi
56
- doi_id = path("$.identification.doi").first ||
57
- path("$.description.identifier[?match(@.type, 'doi|DOI')].value").first ||
58
- path("$.description.identifier[?search(@.uri, 'doi.org')].uri").first
59
-
60
- URI(doi_id).path.delete_prefix("/") if doi_id.present?
61
- end
62
-
63
- # The DOI as a URL, if there is one. Any valid DOI should resolve via doi.org.
64
- # @return [String, nil]
65
- # @example
66
- # record.doi_url #=> "https://doi.org/10.25740/ppax-bf07"
67
- def doi_url
68
- URI.join("https://doi.org", doi).to_s if doi.present?
69
- end
70
-
71
- # The HRID of the item in FOLIO, if defined.
72
- # @note This doesn't imply the object is available in Searchworks at this ID.
73
- # @return [String, nil]
74
- # @example
75
- # record.folio_hrid #=> "a12845814"
76
- def folio_hrid
77
- path("$.identification.catalogLinks[?(@.catalog == 'folio')].catalogRecordId").first
78
- end
79
-
80
- # The FOLIO HRID if defined, otherwise the bare DRUID.
81
- # @note This doesn't imply the object is available in Searchworks at this ID.
82
- # @see folio_hrid
83
- # @see bare_druid
84
- # @return [String]
85
- def searchworks_id
86
- folio_hrid || bare_druid
87
- end
88
-
89
53
  # Timestamp when the Cocina was created.
90
54
  # @note This is for the metadata itself, not the object.
91
55
  # @return [Time]
@@ -115,28 +79,6 @@ module CocinaDisplay
115
79
  content_type == "collection"
116
80
  end
117
81
 
118
- # The main title for the object.
119
- # @note If you need more formatting control, consider using {CocinaDisplay::TitleBuilder} directly.
120
- # @return [String]
121
- # @example
122
- # record.title #=> "Bugatti Type 51A. Road & Track Salon January 1957"
123
- def title
124
- CocinaDisplay::TitleBuilder.build(
125
- cocina_doc.dig("description", "title"),
126
- catalog_links: cocina_doc.dig("identification", "catalogLinks")
127
- )
128
- end
129
-
130
- # Alternative or translated titles for the object. Does not include the main title.
131
- # @return [Array<String>]
132
- # @example
133
- # record.additional_titles #=> ["Alternate title 1", "Alternate title 2"]
134
- def additional_titles
135
- CocinaDisplay::TitleBuilder.additional_titles(
136
- cocina_doc.dig("description", "title")
137
- )
138
- end
139
-
140
82
  # Traverse nested FileSets and return an enumerator over their files.
141
83
  # Each file is a +Hash+.
142
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,137 @@
1
+ require_relative "../dates/date"
2
+ require_relative "../dates/date_range"
3
+ require_relative "../imprint"
4
+
5
+ module CocinaDisplay
6
+ module Concerns
7
+ module Events
8
+ # The earliest preferred publication date as a Date object.
9
+ # If the date was a range or interval, uses the start (or end if no start).
10
+ # Considers publication, creation, and capture dates in that order.
11
+ # Prefers dates marked as primary and those with a declared encoding.
12
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
13
+ # @return [Date, nil]
14
+ # @see https://github.com/inukshuk/edtf-ruby
15
+ def pub_date_edtf(ignore_qualified: false)
16
+ date = pub_date(ignore_qualified: ignore_qualified)
17
+ return unless date
18
+
19
+ if date.is_a? CocinaDisplay::Dates::DateRange
20
+ date = date.start || date.stop
21
+ end
22
+
23
+ edtf_date = date.date
24
+ return unless edtf_date
25
+
26
+ if edtf_date.is_a? EDTF::Interval
27
+ edtf_date.from
28
+ else
29
+ edtf_date
30
+ end
31
+ end
32
+
33
+ # The earliest preferred publication year as an integer.
34
+ # If the date was a range or interval, uses the start (or end if no start).
35
+ # Considers publication, creation, and capture dates in that order.
36
+ # Prefers dates marked as primary and those with a declared encoding.
37
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
38
+ # @return [Integer, nil]
39
+ # @note 6 BCE will return -5; 4 CE will return 4.
40
+ def pub_year_int(ignore_qualified: false)
41
+ pub_date_edtf(ignore_qualified: ignore_qualified)&.year
42
+ end
43
+
44
+ # String for displaying the earliest preferred publication year or range.
45
+ # Considers publication, creation, and capture dates in that order.
46
+ # Prefers dates marked as primary and those with a declared encoding.
47
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
48
+ # @return [String, nil]
49
+ # @example Year range
50
+ # CocinaRecord.fetch('bb099mt5053').pub_year_display_str #=> "1932 - 2012"
51
+ def pub_year_display_str(ignore_qualified: false)
52
+ date = pub_date(ignore_qualified: ignore_qualified)
53
+ return unless date
54
+
55
+ date.decoded_value(allowed_precisions: [:year, :decade, :century])
56
+ end
57
+
58
+ # String for displaying the imprint statement(s).
59
+ # @return [String, nil]
60
+ # @see CocinaDisplay::Imprint#display_str
61
+ # @example
62
+ # CocinaRecord.fetch('bt553vr2845').imprint_display_str #=> "New York : Meridian Book, 1993, c1967"
63
+ def imprint_display_str
64
+ imprints.map(&:display_str).compact_blank.join("; ")
65
+ end
66
+
67
+ private
68
+
69
+ # Event dates as an array of CocinaDisplay::Dates::Date objects.
70
+ # If type is provided, keep dates with a matching event type OR date type.
71
+ # @param type [Symbol, nil] Filter by event type (e.g. :publication).
72
+ # @return [Array<CocinaDisplay::Dates::Date>] The list of event dates
73
+ def event_dates(type: nil)
74
+ filter_expr = type.present? ? "?match(@.type, \"#{type}\")" : "*"
75
+
76
+ Enumerator::Chain.new(
77
+ path("$.description.event[*].date[#{filter_expr}]"),
78
+ path("$.description.event[#{filter_expr}].date[*]")
79
+ ).uniq.map do |date|
80
+ CocinaDisplay::Dates::Date.from_cocina(date)
81
+ end
82
+ end
83
+
84
+ # Array of CocinaDisplay::Imprint objects for all relevant Cocina events.
85
+ # Considers publication, creation, capture, and copyright events.
86
+ # Considers event types as well as date types if the event is untyped.
87
+ # Prefers events where the date was not encoded, if any.
88
+ # @return [Array<CocinaDisplay::Imprint>] The list of Imprint objects
89
+ def imprints
90
+ filter_expr = "\"(publication|creation|capture|copyright)\""
91
+
92
+ imprints = Enumerator::Chain.new(
93
+ path("$.description.event[?match(@.type, #{filter_expr})]"),
94
+ path("$.description.event[?@.date[?match(@.type, #{filter_expr})]]")
95
+ ).uniq.map do |event|
96
+ CocinaDisplay::Imprint.new(event)
97
+ end
98
+
99
+ imprints.reject(&:date_encoding?).presence || imprints
100
+ end
101
+
102
+ # The earliest preferred publication date as a CocinaDisplay::Dates::Date object.
103
+ # Considers publication, creation, and capture dates in that order.
104
+ # Prefers dates marked as primary and those with a declared encoding.
105
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
106
+ # @return [CocinaDisplay::Dates::Date] The earliest preferred date
107
+ # @return [nil] if no dates are left after filtering
108
+ def pub_date(ignore_qualified: false)
109
+ [:publication, :creation, :capture].map do |type|
110
+ earliest_preferred_date(event_dates(type: type), ignore_qualified: ignore_qualified)
111
+ end.compact.first
112
+ end
113
+
114
+ # Choose the earliest, best date from a provided list of event dates.
115
+ # Rules to consider:
116
+ # 1. Reject any dates that were not parsed.
117
+ # 2. If `ignore_qualified` is true, reject any qualified dates.
118
+ # 3. If there are any primary dates, prefer those dates.
119
+ # 4. If there are any encoded dates, prefer those dates.
120
+ # 5. From whatever is left, choose the earliest date.
121
+ # @param dates [Array<CocinaDisplay::Dates::Date>] The list of dates
122
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
123
+ # @return [CocinaDisplay::Dates::Date] The earliest preferred date
124
+ # @return [nil] if no dates are left after filtering
125
+ def earliest_preferred_date(dates, ignore_qualified: false)
126
+ return nil if dates.empty?
127
+
128
+ dates.filter!(&:parsed_date?)
129
+ dates.reject!(&:approximate?) if ignore_qualified
130
+ dates = dates.filter(&:primary?).presence || dates
131
+ dates = dates.filter(&:encoding?).presence || dates
132
+
133
+ dates.min
134
+ end
135
+ end
136
+ end
137
+ 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