cocina_display 0.5.0 → 0.6.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: 5662a1512975323d0324c31021fcef9cd4ff337ea8acfb5f6a3352d4abf0e303
4
- data.tar.gz: fe0444d5c104e393718a996260346f98691f2e84585483bb7a2062acab347bfb
3
+ metadata.gz: e86f2100a204e574eaf48f86dccf3b9bdc4985285e72e05292f9dd722d9d87e9
4
+ data.tar.gz: 5730dd6764fa87f39dc6d00dc66c8ea72f10685c7e3aef0b92864c16b1186cb6
5
5
  SHA512:
6
- metadata.gz: 8ff64dadfe8b2492f23d38bcc320e8de4bc6100af09adfd93d25df0aeb84c91585072dbcbb6cafc297bd503d773ee7b6bf35281fdda1d573e65f94fcb866a5a0
7
- data.tar.gz: 29c42ee65d59dbac907159435f8c9924742cc4d598e656a787dee8cbfceaab8231f2bbf8cb87d14ee180ed586fc51b4d498e8009b6fd5442442d8b0932ebd30d
6
+ metadata.gz: fcfa03dd80c3673045803c11c1d7fa3f9c5284f75806c7c76b5ccb52cb765631670696fe3777a2a5cfbcd90b209ba5e898a618a92d9ecf0186670fdddb09cb6e
7
+ data.tar.gz: acbcb5d312ac4755cfff3a74119b68e316d929da0d0d91f397afdd51480f3a90332166f2ccc052e53c2d675d749f62a12f814513b58413611475e36d732c5041
data/README.md CHANGED
@@ -24,11 +24,11 @@ gem install cocina_display
24
24
 
25
25
  ### Obtaining Cocina
26
26
 
27
- To start, you need some Cocina in JSON form.
27
+ To start, you need some Cocina in JSON form. Consumers of this gem are likely to be applications that are already harvesting this data (for indexing) or have it stored in an index or database (for display). In testing, you may have neither of these.
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
- There is also a helper method to fetch the Cocina JSON for a given DRUID and immediately parse it into a `CocinaRecord` object:
31
+ There is also a helper method to fetch the Cocina JSON for a given DRUID over HTTP and immediately parse it into a `CocinaRecord` object:
32
32
 
33
33
  ```ruby
34
34
  > record = CocinaDisplay::CocinaRecord.fetch('bb112zx3193')
@@ -51,7 +51,7 @@ The `CocinaRecord` class provides some methods to access common fields, as well
51
51
  => "Hearst Magazines, Inc."
52
52
  ```
53
53
 
54
- See the [API Documentation](https://sul-dlss.github.io/cocina_display/CocinaDisplay/CocinaRecord.html) for more details on the methods available in the `CocinaRecord` class.
54
+ See the [API Documentation](https://sul-dlss.github.io/cocina_display/CocinaDisplay/CocinaRecord.html) for more details on the methods available in the `CocinaRecord` class. The gem provides a large number of methods, organized into concerns, to access different parts of the data.
55
55
 
56
56
  ### Fetching nested data
57
57
 
@@ -61,47 +61,52 @@ The previous example used `Hash#dig` to access the first contributor's first nam
61
61
 
62
62
  ```ruby
63
63
  # name values for all contributors in description
64
- > record.path('$.description.contributor[*].name[*].value').search
64
+ > record.path('$.description.contributor.*.name.*.value').search
65
65
  => ["Hearst Magazines, Inc.", "Chesebrough, Jerry"]
66
66
  # only contributors with a role with value "photographer"
67
- > record.path("$.description.contributor[?@.role[?@.value == 'photographer']].name[*].value").search
67
+ > record.path("$.description.contributor[?@.role[?@.value == 'photographer']].name.*.value").search
68
68
  => ["Chesebrough, Jerry"]
69
69
  ```
70
70
 
71
71
  The JsonPath implementation used is [janeway](https://www.rubydoc.info/gems/janeway-jsonpath/0.6.0/file/README.md), which supports the full syntax from the [finalized 2024 version of the specification](https://www.rfc-editor.org/rfc/rfc9535.html). Results returned from `#path` are Enumerators.
72
72
 
73
- In the following example, we start an expression with `"$.."` to search for contributor nodes at _any_ level (e.g. `event.contributors`) and discover that there is a third contributor, but it has no `name` value. Using the `['code', 'value']` syntax, we can retrieve both `code` and `value` and show where they came from:
73
+ In the following example, we start an expression with `"$.."` to search for contributor nodes at _any_ level (e.g. `event.contributors`) and discover that there is a third contributor, but it has no `name` value. Using the `['code', 'value']` syntax, we can retrieve both `code` and `value` and show the path they came from:
74
74
 
75
75
  ```ruby
76
- > record.path("$..contributor[*].name[*]['code', 'value']").each { |value, node, key| puts "#{key}: #{value} (from #{node})" }
77
- value: Hearst Magazines, Inc. (from {"structuredValue"=>[], "parallelValue"=>[], "groupedValue"=>[], "value"=>"Hearst Magazines, Inc.", "uri"=>"http://id.loc.gov/authorities/names/n2015050736", "identifier"=>[], "source"=>{"code"=>"naf", "uri"=>"http://id.loc.gov/authorities/names/", "note"=>[]}, "note"=>[], "appliesTo"=>[]})
78
- value: Chesebrough, Jerry (from {"structuredValue"=>[], "parallelValue"=>[], "groupedValue"=>[], "value"=>"Chesebrough, Jerry", "identifier"=>[], "note"=>[], "appliesTo"=>[]})
79
- code: CSt (from {"structuredValue"=>[], "parallelValue"=>[], "groupedValue"=>[], "code"=>"CSt", "uri"=>"http://id.loc.gov/vocabulary/organizations/cst", "identifier"=>[], "source"=>{"code"=>"marcorg", "uri"=>"http://id.loc.gov/vocabulary/organizations", "note"=>[]}, "note"=>[], "appliesTo"=>[]})
80
- => ["Hearst Magazines, Inc.", "Chesebrough, Jerry", "CSt"]
76
+ > record.path("$..contributor.*.name[*]['code', 'value']").map { |value, _node, key, path| [key, value, path] }
77
+ [["value", "Hearst Magazines, Inc.", "$['description']['contributor'][0]['name'][0]['value']"],
78
+ ["value", "Chesebrough, Jerry", "$['description']['contributor'][1]['name'][0]['value']"],
79
+ ["code", "CSt", "$['description']['adminMetadata']['contributor'][0]['name'][0]['code']"]]
81
80
  ```
82
81
 
83
82
  There is also a command line utility for quickly querying a JSON file using JsonPath. Online syntax checkers may give different results, so it helps to test locally. You can run it with:
84
83
 
85
84
  ```bash
86
- cat spec/fixtures/bb112zx3193.json | janeway "$.description.contributor[?@.role[?@.value == 'photographer']].name[*].value"
85
+ cat spec/fixtures/bb112zx3193.json | janeway "$.description.contributor[?@.role[?@.value == 'photographer']].name.*.value"
87
86
  [
88
87
  "Chesebrough, Jerry"
89
88
  ]
90
89
  ```
91
90
 
91
+ ### Searching for records
92
+
93
+ Sometimes you need to determine if records exist "in the wild" that exhibit particular characteristics in the Cocina metadata, like the presence or absence of a field, or a specific value in a field. There is a template script in the `scripts/` directory that can be used to crawl all DRUIDs released to a particular target, like Searchworks, and examine each record.
94
+
92
95
  ## Development
93
96
 
94
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
97
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. This is a useful place to try out JSONPath expressions with `CocinaRecord#path`.
98
+
99
+ Tests are written using [rspec](https://rspec.info), with coverage automatically measured via [simplecov](https://github.com/simplecov-ruby/simplecov). CI will fail if coverage drops below 100%. For convenience, if you invoke a single spec file locally, coverage will not be reported.
95
100
 
96
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
101
+ Documentation is generated using [yard](https://yardoc.org). You can generate it locally by running `yardoc`, or `yard server --reload` to start a local server and watch for changes as you edit. There is a GitHub action that automatically generates and publishes the documentation to GitHub Pages on every push/merge to `main`.
97
102
 
98
- Documentation is generated using [yard](https://yardoc.org). You can generate it by running `yardoc`, or `yard server --reload` to start a local server and watch for changes as you edit.
103
+ To release a new version, update the version number in `version.rb`, run `bundle` to update `Gemfile.lock`, commit your changes, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
99
104
 
100
105
  ## Background
101
106
 
102
107
  Historically, applications at SUL used a combination of several gems to render objects represented by MODS XML. With the transition to the Cocina data model, infrastructure applications adopted the [cocina-models gem](https://github.com/sul-dlss/cocina-models), which provides accessor objects and validators over Cocina JSON. Internal applications can fetch such objects over HTTP using [dor-services-client](https://github.com/sul-dlss/dor-services-client).
103
108
 
104
- On the access side, Cocina JSON (the "public Cocina") is available statically via [PURL](https://purl.stanford.edu), but is only updated when an object is published ("shelved") from SDR. This frequently results in data that is technically invalid with respect to `cocina-models` but is still valid in the context of a patron-facing application.
109
+ On the access side, Cocina JSON (the "public Cocina") is available statically via [PURL](https://purl.stanford.edu), but is only updated when an object is published ("shelved") from SDR. This frequently results in data that is technically invalid with respect to `cocina-models` (i.e. it does not match the latest spec) but is still valid in the context of a patron-facing application (because it can still be rendered into useful information).
105
110
 
106
111
  Cocina data can also be complex, representing the same underlying information in different ways. A "complete" implementation can involve checking multiple deeply nested paths to ensure no information is missed. Rather than tightly coupling access applications to `cocina-models`, this gem provides a set of helpers designed to safely parse Cocina JSON and render it in a consistent way across applications.
107
112
 
@@ -13,6 +13,8 @@ require_relative "concerns/identifiers"
13
13
  require_relative "concerns/titles"
14
14
  require_relative "concerns/access"
15
15
  require_relative "concerns/subjects"
16
+ require_relative "concerns/forms"
17
+ require_relative "utils"
16
18
 
17
19
  module CocinaDisplay
18
20
  # Public Cocina metadata for an SDR object, as fetched from PURL.
@@ -23,23 +25,36 @@ module CocinaDisplay
23
25
  include CocinaDisplay::Concerns::Titles
24
26
  include CocinaDisplay::Concerns::Access
25
27
  include CocinaDisplay::Concerns::Subjects
28
+ include CocinaDisplay::Concerns::Forms
26
29
 
27
30
  # Fetch a public Cocina document from PURL and create a CocinaRecord.
28
31
  # @note This is intended to be used in development or testing only.
29
32
  # @param druid [String] The bare DRUID of the object to fetch.
33
+ # @param deep_compact [Boolean] If true, compact the JSON to remove blank values.
30
34
  # @return [CocinaDisplay::CocinaRecord]
31
35
  # :nocov:
32
- def self.fetch(druid)
33
- new(Net::HTTP.get(URI("https://purl.stanford.edu/#{druid}.json")))
36
+ def self.fetch(druid, deep_compact: false)
37
+ from_json(Net::HTTP.get(URI("https://purl.stanford.edu/#{druid}.json")), deep_compact: deep_compact)
34
38
  end
35
39
  # :nocov:
36
40
 
41
+ # Create a CocinaRecord from a JSON string.
42
+ # @param cocina_json [String]
43
+ # @param deep_compact [Boolean] If true, compact the JSON to remove blank values.
44
+ # @return [CocinaDisplay::CocinaRecord]
45
+ def self.from_json(cocina_json, deep_compact: false)
46
+ cocina_doc = JSON.parse(cocina_json)
47
+ deep_compact ? new(Utils.deep_compact_blank(cocina_doc)) : new(cocina_doc)
48
+ end
49
+
37
50
  # The parsed Cocina document.
38
51
  # @return [Hash]
39
52
  attr_reader :cocina_doc
40
53
 
41
- def initialize(cocina_json)
42
- @cocina_doc = JSON.parse(cocina_json)
54
+ # Initialize a CocinaRecord with a Cocina document hash.
55
+ # @param cocina_doc [Hash]
56
+ def initialize(cocina_doc)
57
+ @cocina_doc = cocina_doc
43
58
  end
44
59
 
45
60
  # Evaluate a JSONPath expression against the Cocina document.
@@ -47,9 +62,9 @@ module CocinaDisplay
47
62
  # @param path_expression [String] The JSONPath expression to evaluate.
48
63
  # @see https://www.rubydoc.info/gems/janeway-jsonpath/0.6.0/file/README.md
49
64
  # @example Name values for contributors
50
- # record.path("$.description.contributor[*].name[*].value").search #=> ["Smith, John", "ACME Corp."]
65
+ # record.path("$.description.contributor.*.name.*.value").search #=> ["Smith, John", "ACME Corp."]
51
66
  # @example Filtering nodes using a condition
52
- # record.path("$.description.contributor[?(@.type == 'person')].name[*].value").search #=> ["Smith, John"]
67
+ # record.path("$.description.contributor[?(@.type == 'person')].name.*.value").search #=> ["Smith, John"]
53
68
  def path(path_expression)
54
69
  Janeway.enum_for(path_expression, cocina_doc)
55
70
  end
@@ -99,7 +114,7 @@ module CocinaDisplay
99
114
  # puts file["size"] #=> 123456
100
115
  # end
101
116
  def files
102
- path("$.structural.contains[*].structural.contains[*]")
117
+ path("$.structural.contains.*.structural.contains[*]")
103
118
  end
104
119
  end
105
120
  end
@@ -4,90 +4,109 @@ module CocinaDisplay
4
4
  module Concerns
5
5
  # Methods for finding and formatting names for contributors
6
6
  module Contributors
7
- # The main author's name, formatted for display.
7
+ # The main contributor's name, formatted for display.
8
8
  # @param with_date [Boolean] Include life dates, if present
9
9
  # @return [String]
10
- # @return [nil] if no main author is found
10
+ # @return [nil] if no main contributor is found
11
11
  # @example
12
- # record.main_author #=> "Smith, John"
12
+ # record.main_contributor_name #=> "Smith, John"
13
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)
14
+ # record.main_contributor_name(with_date: true) #=> "Smith, John, 1970-2020"
15
+ def main_contributor_name(with_date: false)
16
+ main_contributor&.display_name(with_date: with_date)
17
17
  end
18
18
 
19
- # All author names except the main one, formatted for display.
19
+ # All contributor names except the main one, formatted for display.
20
20
  # @param with_date [Boolean] Include life dates, if present
21
21
  # @return [Array<String>]
22
- def additional_authors(with_date: false)
23
- additional_author_contributors.map { |c| c.display_name(with_date: with_date) }
22
+ def additional_contributor_names(with_date: false)
23
+ additional_contributors.map { |c| c.display_name(with_date: with_date) }
24
+ end
25
+
26
+ # All names of publishers, formatted for display.
27
+ # @return [Array<String>]
28
+ def publisher_names
29
+ publisher_contributors.map(&:display_name)
24
30
  end
25
31
 
26
32
  # All names of authors who are people, formatted for display.
27
33
  # @param with_date [Boolean] Include life dates, if present
28
34
  # @return [Array<String>]
29
- def person_authors(with_date: false)
30
- authors.filter(&:person?).map { |c| c.display_name(with_date: with_date) }
35
+ def person_contributor_names(with_date: false)
36
+ contributors.filter(&:person?).map { |c| c.display_name(with_date: with_date) }
31
37
  end
32
38
 
33
- # All names of non-person authors, formatted for display.
39
+ # All names of non-person contributors, formatted for display.
34
40
  # This includes organizations, conferences, families, etc.
35
41
  # @return [Array<String>]
36
42
  # @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)
43
+ def impersonal_contributor_names
44
+ contributors.reject(&:person?).map(&:display_name)
39
45
  end
40
46
 
41
- # All names of authors that are organizations, formatted for display.
47
+ # All names of contributors that are organizations, formatted for display.
42
48
  # @return [Array<String>]
43
- def organization_authors
44
- authors.filter(&:organization?).map(&:display_name)
49
+ def organization_contributor_names
50
+ contributors.filter(&:organization?).map(&:display_name)
45
51
  end
46
52
 
47
- # All names of authors that are conferences, formatted for display.
53
+ # All names of contributors that are conferences, formatted for display.
48
54
  # @return [Array<String>]
49
- def conference_authors
50
- authors.filter(&:conference?).map(&:display_name)
55
+ def conference_contributor_names
56
+ contributors.filter(&:conference?).map(&:display_name)
51
57
  end
52
58
 
53
- # A string value for sorting by author that sorts missing values last.
59
+ # A hash mapping role names to the names of contributors with that role.
60
+ # @param with_date [Boolean] Include life dates, if present
61
+ # @return [Hash<String, Array<String>>]
62
+ def contributor_names_by_role(with_date: false)
63
+ contributors.each_with_object({}) do |contributor, hash|
64
+ contributor.roles.each do |role|
65
+ hash[role.display_str] ||= []
66
+ hash[role.display_str] << contributor.display_name(with_date: with_date)
67
+ end
68
+ end
69
+ end
70
+
71
+ # A string value for sorting by contributor that sorts missing values last.
72
+ # Appends the sort title to break ties between contributor names.
54
73
  # Ignores punctuation and leading/trailing spaces.
55
74
  # @return [String]
56
- def sort_author
57
- (main_author_contributor&.display_name || "\u{10FFFF}").gsub(/[[:punct:]]*/, "").strip
75
+ def sort_contributor_name
76
+ sort_name = main_contributor&.display_name || "\u{10FFFF}"
77
+ sort_name_title = [sort_name, sort_title].join(" ")
78
+ sort_name_title.gsub(/[[:punct:]]*/, "").strip
58
79
  end
59
80
 
60
- private
61
-
62
81
  # All contributors for the object, including authors, editors, etc.
63
82
  # @return [Array<Contributor>]
64
83
  def contributors
65
- @contributors ||= path("$.description.contributor[*]").map { |c| Contributor.new(c) }
84
+ @contributors ||= path("$.description.contributor.*").map { |c| Contributor.new(c) }
66
85
  end
67
86
 
68
- # All contributors with a "creator" or "author" role.
87
+ # All contributors with a "publisher" role.
69
88
  # @return [Array<Contributor>]
70
- # @see Contributor#author?
71
- def authors
72
- contributors.filter(&:author?)
89
+ # @see Contributor#publisher?
90
+ def publisher_contributors
91
+ contributors.filter(&:publisher?)
73
92
  end
74
93
 
75
- # Contributor object representing the primary author.
94
+ # Object representing the main contributor.
76
95
  # 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.
96
+ # 1. If there are contributors marked as primary, use the first one.
97
+ # 2. If there are no primary contributors, use the first contributor with no role.
98
+ # 3. If there are no contributors without a role, use the first contributor.
80
99
  # @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
100
+ # @return [nil] if there are no contributors at all
101
+ def main_contributor
102
+ contributors.find(&:primary?).presence || contributors.find { |c| !c.role? }.presence || contributors.first
84
103
  end
85
104
 
86
- # All author/creator contributors except the main one.
105
+ # All contributors except the main one.
87
106
  # @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]
107
+ def additional_contributors
108
+ return [] if contributors.empty? || contributors.one?
109
+ contributors - [main_contributor]
91
110
  end
92
111
  end
93
112
  end
@@ -1,6 +1,7 @@
1
1
  require_relative "../dates/date"
2
2
  require_relative "../dates/date_range"
3
- require_relative "../imprint"
3
+ require_relative "../events/event"
4
+ require_relative "../events/imprint"
4
5
 
5
6
  module CocinaDisplay
6
7
  module Concerns
@@ -75,24 +76,28 @@ module CocinaDisplay
75
76
  # @example
76
77
  # CocinaRecord.fetch('bt553vr2845').imprint_display_str #=> "New York : Meridian Book, 1993, c1967"
77
78
  def imprint_display_str
78
- imprints.map(&:display_str).compact_blank.join("; ")
79
+ imprint_events.map(&:display_str).compact_blank.join("; ")
79
80
  end
80
81
 
81
- private
82
+ # List of places of publication as strings.
83
+ # Considers locations for all publication, creation, and capture events.
84
+ # @return [Array<String>]
85
+ def publication_places
86
+ publication_events.flat_map { |event| event.locations.map(&:display_str) }
87
+ end
82
88
 
83
- # Event dates as an array of CocinaDisplay::Dates::Date objects.
84
- # If type is provided, keep dates with a matching event type OR date type.
85
- # @param type [Symbol, nil] Filter by event type (e.g. :publication).
86
- # @return [Array<CocinaDisplay::Dates::Date>] The list of event dates
87
- def event_dates(type: nil)
88
- filter_expr = type.present? ? "?match(@.type, \"#{type}\")" : "*"
89
+ # All events associated with the object.
90
+ # @return [Array<CocinaDisplay::Events::Event>]
91
+ def events
92
+ @events ||= path("$.description.event.*").map { |event| CocinaDisplay::Events::Event.new(event) }
93
+ end
89
94
 
90
- Enumerator::Chain.new(
91
- path("$.description.event[*].date[#{filter_expr}]"),
92
- path("$.description.event[#{filter_expr}].date[*]")
93
- ).uniq.map do |date|
94
- CocinaDisplay::Dates::Date.from_cocina(date)
95
- end
95
+ # All events that could be used to select a publication date.
96
+ # Includes publication, creation, and capture events.
97
+ # Considers event types as well as date types if the event is untyped.
98
+ # @return [Array<CocinaDisplay::Events::Event>]
99
+ def publication_events
100
+ events.filter { |event| event.has_any_type?("publication", "creation", "capture") }
96
101
  end
97
102
 
98
103
  # Array of CocinaDisplay::Imprint objects for all relevant Cocina events.
@@ -100,19 +105,22 @@ module CocinaDisplay
100
105
  # Considers event types as well as date types if the event is untyped.
101
106
  # Prefers events where the date was not encoded, if any.
102
107
  # @return [Array<CocinaDisplay::Imprint>] The list of Imprint objects
103
- def imprints
104
- filter_expr = "\"(publication|creation|capture|copyright)\""
105
-
106
- imprints = Enumerator::Chain.new(
107
- path("$.description.event[?match(@.type, #{filter_expr})]"),
108
- path("$.description.event[?@.date[?match(@.type, #{filter_expr})]]")
109
- ).uniq.map do |event|
110
- CocinaDisplay::Imprint.new(event)
108
+ def imprint_events
109
+ imprints = events.filter do |event|
110
+ event.has_any_type?("publication", "creation", "capture", "copyright")
111
+ end.map do |event|
112
+ CocinaDisplay::Events::Imprint.new(event.cocina)
111
113
  end
112
114
 
113
115
  imprints.reject(&:date_encoding?).presence || imprints
114
116
  end
115
117
 
118
+ # All dates associated with the object via an event.
119
+ # @return [Array<CocinaDisplay::Dates::Date>]
120
+ def event_dates
121
+ @event_dates ||= events.flat_map(&:dates)
122
+ end
123
+
116
124
  # The earliest preferred publication date as a CocinaDisplay::Dates::Date object.
117
125
  # Considers publication, creation, and capture dates in that order.
118
126
  # Prefers dates marked as primary and those with a declared encoding.
@@ -120,8 +128,12 @@ module CocinaDisplay
120
128
  # @return [CocinaDisplay::Dates::Date] The earliest preferred date
121
129
  # @return [nil] if no dates are left after filtering
122
130
  def pub_date(ignore_qualified: false)
123
- [:publication, :creation, :capture].map do |type|
124
- earliest_preferred_date(event_dates(type: type), ignore_qualified: ignore_qualified)
131
+ pub_event_dates = event_dates.filter { |date| date.type == "publication" }
132
+ creation_event_dates = event_dates.filter { |date| date.type == "creation" }
133
+ capture_event_dates = event_dates.filter { |date| date.type == "capture" }
134
+
135
+ [pub_event_dates, creation_event_dates, capture_event_dates].flat_map do |dates|
136
+ earliest_preferred_date(dates, ignore_qualified: ignore_qualified)
125
137
  end.compact.first
126
138
  end
127
139
 
@@ -0,0 +1,134 @@
1
+ require "active_support/core_ext/enumerable"
2
+
3
+ module CocinaDisplay
4
+ module Concerns
5
+ # Methods for extracting format/genre information from a Cocina object
6
+ module Forms
7
+ # Resource types of the object, expressed in SearchWorks controlled vocabulary.
8
+ # @return [Array<String>]
9
+ def resource_types
10
+ mapped_values = resource_type_values.flat_map { |resource_type| searchworks_resource_type(resource_type) }
11
+ mapped_values << "Dataset" if dataset?
12
+ mapped_values.uniq
13
+ end
14
+
15
+ # Physical or digital forms of the object.
16
+ # @return [Array<String>]
17
+ # @example GIS dataset (nz187ct8959)
18
+ # record.forms #=> ["map", "optical disc", "electronic resource"]
19
+ def forms
20
+ path("$.description.form..[?@.type == 'form'].value").uniq
21
+ end
22
+
23
+ # Extent of the object, such as "1 audiotape" or "1 map".
24
+ # @return [Array<String>]
25
+ # @example Oral history interview (sw705fr7011)
26
+ # record.extents #=> ["1 audiotape", "1 transcript"]
27
+ def extents
28
+ path("$.description.form..[?@.type == 'extent'].value").uniq
29
+ end
30
+
31
+ # Genres of the object, capitalized for display.
32
+ # @return [Array<String>]
33
+ # @example GIS dataset (nz187ct8959)
34
+ # record.genres #=> ["Cartographic dataset", "Geospatial data", "Geographic information systems data"]
35
+ def genres
36
+ path("$.description.form..[?@.type == 'genre'].value").map(&:upcase_first).uniq
37
+ end
38
+
39
+ # Genres of the object, with additional values added for search/faceting.
40
+ # @note These values are added for discovery in SearchWorks but not for display.
41
+ # @return [Array<String>]
42
+ def genres_search
43
+ genres.tap do |values|
44
+ values << "Thesis/Dissertation" if values.include?("Thesis")
45
+ values << "Conference proceedings" if values.include?("Conference publication")
46
+ values << "Government document" if values.include?("Government publication")
47
+ end.uniq
48
+ end
49
+
50
+ # Is the object a periodical or serial?
51
+ # @return [Boolean]
52
+ def periodical?
53
+ issuance_terms.include?("periodical") || issuance_terms.include?("serial") || frequency.any?
54
+ end
55
+
56
+ # Is the object a cartographic resource?
57
+ # @return [Boolean]
58
+ def cartographic?
59
+ resource_type_values.include?("cartographic")
60
+ end
61
+
62
+ # Is the object a web archive?
63
+ # @return [Boolean]
64
+ def archived_website?
65
+ genres.include?("Archived website")
66
+ end
67
+
68
+ # Is the object a dataset?
69
+ # @return [Boolean]
70
+ def dataset?
71
+ genres.include?("Dataset")
72
+ end
73
+
74
+ private
75
+
76
+ # Map a resource type to SearchWorks format value(s).
77
+ # @param resource_type [String] The resource type to map.
78
+ # @return [Array<String>]
79
+ def searchworks_resource_type(resource_type)
80
+ values = []
81
+
82
+ case resource_type
83
+ when "cartographic"
84
+ values << "Map"
85
+ when "manuscript", "mixed material"
86
+ values << "Archive/Manuscript"
87
+ when "moving image"
88
+ values << "Video"
89
+ when "notated music"
90
+ values << "Music score"
91
+ when "software, multimedia"
92
+ # Prevent GIS datasets from being labeled as "Software"
93
+ values << "Software/Multimedia" unless cartographic? || dataset?
94
+ when "sound recording-musical"
95
+ values << "Music recording"
96
+ when "sound recording-nonmusical", "sound recording"
97
+ values << "Sound recording"
98
+ when "still image"
99
+ values << "Image"
100
+ when "text"
101
+ # Can potentially map to periodical AND website if both are true. Only
102
+ # 2 records currently (2025) in Searchworks do this, but it is real.
103
+ if periodical? || archived_website?
104
+ values << "Journal/Periodical" if periodical?
105
+ values << "Archived website" if archived_website?
106
+ else
107
+ values << "Book"
108
+ end
109
+ when "three dimensional object"
110
+ values << "Object"
111
+ end
112
+
113
+ values.compact_blank
114
+ end
115
+
116
+ # Issuance terms for a work, drawn from the event notes.
117
+ # @return [Array<String>]
118
+ def issuance_terms
119
+ path("$.description.event.*.note[?@.type == 'issuance'].value").map(&:downcase).uniq
120
+ end
121
+
122
+ # Frequency terms for a periodical, drawn from the event notes.
123
+ # @return [Array<String>]
124
+ def frequency
125
+ path("$.description.event.*.note[?@.type == 'frequency'].value").map(&:downcase).uniq
126
+ end
127
+
128
+ # Values of the resource type form field prior to mapping.
129
+ def resource_type_values
130
+ path("$.description.form..[?@.type == 'resource type'].value").uniq
131
+ end
132
+ end
133
+ end
134
+ end
@@ -42,6 +42,39 @@ module CocinaDisplay
42
42
  subjects.filter { |s| s.is_a? NameSubject }.map(&:display_str).uniq
43
43
  end
44
44
 
45
+ # Combination of all subject values for searching.
46
+ # @see #subject_topics_other
47
+ # @see #subject_temporal_genre
48
+ # @return [Array<String>]
49
+ def subject_all
50
+ subject_topics_other + subject_temporal_genre
51
+ end
52
+
53
+ # Combination of topic, occupation, name, and title subject values for searching.
54
+ # @see #subject_topics
55
+ # @see #subject_other
56
+ # @return [Array<String>]
57
+ def subject_topics_other
58
+ subject_topics + subject_other
59
+ end
60
+
61
+ # Combination of occupation, name, and title subject values for searching.
62
+ # @see #subject_occupations
63
+ # @see #subject_names
64
+ # @see #subject_titles
65
+ # @return [Array<String>]
66
+ def subject_other
67
+ subject_occupations + subject_names + subject_titles
68
+ end
69
+
70
+ # Combination of temporal and genre subject values for searching.
71
+ # @see #subject_temporal
72
+ # @see #subject_genres
73
+ # @return [Array<String>]
74
+ def subject_temporal_genre
75
+ subject_temporal + subject_genres
76
+ end
77
+
45
78
  private
46
79
 
47
80
  # All subjects, accessible as Subject objects.
@@ -50,7 +83,7 @@ module CocinaDisplay
50
83
  def subjects
51
84
  @subjects ||= Enumerator::Chain.new(
52
85
  path("$.description.subject[*]"),
53
- path("$.description.geographic[*].subject[*]")
86
+ path("$.description.geographic.*.subject[*]")
54
87
  ).map { |s| Subject.from_cocina(s) }
55
88
  end
56
89
  end