cocina_display 0.4.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: 0724f307088c9cdd9348af463e3c659811eef65d0f1f04e094b4c664a36cfed1
4
- data.tar.gz: 4b49472d07a085455ca72204e6ebde3cfc27c634f8ba9cd5b2c2a47d62d19aad
3
+ metadata.gz: e86f2100a204e574eaf48f86dccf3b9bdc4985285e72e05292f9dd722d9d87e9
4
+ data.tar.gz: 5730dd6764fa87f39dc6d00dc66c8ea72f10685c7e3aef0b92864c16b1186cb6
5
5
  SHA512:
6
- metadata.gz: edc1e8977792d21fd6fef292f486c11eab4011dc63c854af1217aced6930ea55538e158abc261491c08b1f8492c374c52102b44931849b2350e1dacf54310d41
7
- data.tar.gz: 664691764d4a4ff7da024af64ce9dd2d0841d8c014ee9439c3ebebe4b3a4c2e585e7f608f2ff943a04586dfaad8fc4ec7cfa714fade929ca9b0278ec740bff8b
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
 
@@ -11,6 +11,10 @@ require_relative "concerns/events"
11
11
  require_relative "concerns/contributors"
12
12
  require_relative "concerns/identifiers"
13
13
  require_relative "concerns/titles"
14
+ require_relative "concerns/access"
15
+ require_relative "concerns/subjects"
16
+ require_relative "concerns/forms"
17
+ require_relative "utils"
14
18
 
15
19
  module CocinaDisplay
16
20
  # Public Cocina metadata for an SDR object, as fetched from PURL.
@@ -19,23 +23,38 @@ module CocinaDisplay
19
23
  include CocinaDisplay::Concerns::Contributors
20
24
  include CocinaDisplay::Concerns::Identifiers
21
25
  include CocinaDisplay::Concerns::Titles
26
+ include CocinaDisplay::Concerns::Access
27
+ include CocinaDisplay::Concerns::Subjects
28
+ include CocinaDisplay::Concerns::Forms
22
29
 
23
30
  # Fetch a public Cocina document from PURL and create a CocinaRecord.
24
31
  # @note This is intended to be used in development or testing only.
25
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.
26
34
  # @return [CocinaDisplay::CocinaRecord]
27
35
  # :nocov:
28
- def self.fetch(druid)
29
- 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)
30
38
  end
31
39
  # :nocov:
32
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
+
33
50
  # The parsed Cocina document.
34
51
  # @return [Hash]
35
52
  attr_reader :cocina_doc
36
53
 
37
- def initialize(cocina_json)
38
- @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
39
58
  end
40
59
 
41
60
  # Evaluate a JSONPath expression against the Cocina document.
@@ -43,9 +62,9 @@ module CocinaDisplay
43
62
  # @param path_expression [String] The JSONPath expression to evaluate.
44
63
  # @see https://www.rubydoc.info/gems/janeway-jsonpath/0.6.0/file/README.md
45
64
  # @example Name values for contributors
46
- # record.path("$.description.contributor[*].name[*].value").search #=> ["Smith, John", "ACME Corp."]
65
+ # record.path("$.description.contributor.*.name.*.value").search #=> ["Smith, John", "ACME Corp."]
47
66
  # @example Filtering nodes using a condition
48
- # record.path("$.description.contributor[?(@.type == 'person')].name[*].value").search #=> ["Smith, John"]
67
+ # record.path("$.description.contributor[?(@.type == 'person')].name.*.value").search #=> ["Smith, John"]
49
68
  def path(path_expression)
50
69
  Janeway.enum_for(path_expression, cocina_doc)
51
70
  end
@@ -73,6 +92,13 @@ module CocinaDisplay
73
92
  cocina_doc["type"].split("/").last
74
93
  end
75
94
 
95
+ # Primary processing label for the object.
96
+ # @note This may or may not be the same as the title.
97
+ # @return [String, nil]
98
+ def label
99
+ cocina_doc["label"]
100
+ end
101
+
76
102
  # True if the object is a collection.
77
103
  # @return [Boolean]
78
104
  def collection?
@@ -88,72 +114,7 @@ module CocinaDisplay
88
114
  # puts file["size"] #=> 123456
89
115
  # end
90
116
  def files
91
- path("$.structural.contains[*].structural.contains[*]")
92
- end
93
-
94
- # The PURL URL for this object.
95
- # @return [String]
96
- # @example
97
- # record.purl_url #=> "https://purl.stanford.edu/bx658jh7339"
98
- def purl_url
99
- cocina_doc.dig("description", "purl") || "https://purl.stanford.edu/#{bare_druid}"
100
- end
101
-
102
- # The URL to the PURL environment this object is from.
103
- # @note Objects accessed via UAT will still have a production PURL base URL.
104
- # @return [String]
105
- # @example
106
- # record.purl_base_url #=> "https://purl.stanford.edu"
107
- def purl_base_url
108
- URI(purl_url).origin
109
- end
110
-
111
- # The URL to the stacks environment this object is shelved in.
112
- # Corresponds to the PURL environment.
113
- # @see purl_base_url
114
- # @return [String]
115
- # @example
116
- # record.stacks_base_url #=> "https://stacks.stanford.edu"
117
- def stacks_base_url
118
- if purl_base_url == "https://sul-purl-stage.stanford.edu"
119
- "https://sul-stacks-stage.stanford.edu"
120
- else
121
- "https://stacks.stanford.edu"
122
- end
123
- end
124
-
125
- # The oEmbed URL for the object, optionally with additional parameters.
126
- # Corresponds to the PURL environment.
127
- # @param params [Hash] Additional parameters to include in the oEmbed URL.
128
- # @return [String]
129
- # @return [nil] if the object is a collection.
130
- # @example Generate an oEmbed URL for the viewer and hide the title
131
- # record.oembed_url(hide_title: true) #=> "https://purl.stanford.edu/bx658jh7339/embed.json?hide_title=true"
132
- def oembed_url(params: {})
133
- return if collection?
134
-
135
- params[:url] ||= purl_url
136
- "#{purl_base_url}/embed.json?#{params.to_query}"
137
- end
138
-
139
- # The download URL to get the entire object as a .zip file.
140
- # Stacks generates the .zip for the object on request.
141
- # @return [String]
142
- # @example
143
- # record.download_url #=> "https://stacks.stanford.edu/object/bx658jh7339"
144
- def download_url
145
- "#{stacks_base_url}/object/#{bare_druid}"
146
- end
147
-
148
- # The IIIF manifest URL for the object.
149
- # PURL generates the IIIF manifest.
150
- # @param version [Integer] The IIIF presentation spec version to use (3 or 2).
151
- # @return [String]
152
- # @example
153
- # record.iiif_manifest_url #=> "https://purl.stanford.edu/bx658jh7339/iiif3/manifest"
154
- def iiif_manifest_url(version: 3)
155
- iiif_path = (version == 3) ? "iiif3" : "iiif"
156
- "#{purl_url}/#{iiif_path}/manifest"
117
+ path("$.structural.contains.*.structural.contains[*]")
157
118
  end
158
119
  end
159
120
  end
@@ -0,0 +1,71 @@
1
+ module CocinaDisplay
2
+ module Concerns
3
+ # Methods that generate URLs to access an object.
4
+ module Access
5
+ # The PURL URL for this object.
6
+ # @return [String]
7
+ # @example
8
+ # record.purl_url #=> "https://purl.stanford.edu/bx658jh7339"
9
+ def purl_url
10
+ cocina_doc.dig("description", "purl") || "https://purl.stanford.edu/#{bare_druid}"
11
+ end
12
+
13
+ # The URL to the PURL environment this object is from.
14
+ # @note Objects accessed via UAT will still have a production PURL base URL.
15
+ # @return [String]
16
+ # @example
17
+ # record.purl_base_url #=> "https://purl.stanford.edu"
18
+ def purl_base_url
19
+ URI(purl_url).origin
20
+ end
21
+
22
+ # The URL to the stacks environment this object is shelved in.
23
+ # Corresponds to the PURL environment.
24
+ # @see purl_base_url
25
+ # @return [String]
26
+ # @example
27
+ # record.stacks_base_url #=> "https://stacks.stanford.edu"
28
+ def stacks_base_url
29
+ if purl_base_url == "https://sul-purl-stage.stanford.edu"
30
+ "https://sul-stacks-stage.stanford.edu"
31
+ else
32
+ "https://stacks.stanford.edu"
33
+ end
34
+ end
35
+
36
+ # The oEmbed URL for the object, optionally with additional parameters.
37
+ # Corresponds to the PURL environment.
38
+ # @param params [Hash] Additional parameters to include in the oEmbed URL.
39
+ # @return [String]
40
+ # @return [nil] if the object is a collection.
41
+ # @example Generate an oEmbed URL for the viewer and hide the title
42
+ # record.oembed_url(hide_title: true) #=> "https://purl.stanford.edu/bx658jh7339/embed.json?hide_title=true"
43
+ def oembed_url(params: {})
44
+ return if collection?
45
+
46
+ params[:url] ||= purl_url
47
+ "#{purl_base_url}/embed.json?#{params.to_query}"
48
+ end
49
+
50
+ # The download URL to get the entire object as a .zip file.
51
+ # Stacks generates the .zip for the object on request.
52
+ # @return [String]
53
+ # @example
54
+ # record.download_url #=> "https://stacks.stanford.edu/object/bx658jh7339"
55
+ def download_url
56
+ "#{stacks_base_url}/object/#{bare_druid}"
57
+ end
58
+
59
+ # The IIIF manifest URL for the object.
60
+ # PURL generates the IIIF manifest.
61
+ # @param version [Integer] The IIIF presentation spec version to use (3 or 2).
62
+ # @return [String]
63
+ # @example
64
+ # record.iiif_manifest_url #=> "https://purl.stanford.edu/bx658jh7339/iiif3/manifest"
65
+ def iiif_manifest_url(version: 3)
66
+ iiif_path = (version == 3) ? "iiif3" : "iiif"
67
+ "#{purl_url}/#{iiif_path}/manifest"
68
+ end
69
+ end
70
+ end
71
+ 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
@@ -41,6 +42,20 @@ module CocinaDisplay
41
42
  pub_date_edtf(ignore_qualified: ignore_qualified)&.year
42
43
  end
43
44
 
45
+ # The range of preferred publication years as an array of integers.
46
+ # Considers publication, creation, and capture dates in that order.
47
+ # Prefers dates marked as primary and those with a declared encoding.
48
+ # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
49
+ # @return [Array<Integer>, nil]
50
+ # @note 6 BCE will appear as -5; 4 CE will appear as 4.
51
+ def pub_year_int_range(ignore_qualified: false)
52
+ date = pub_date(ignore_qualified: ignore_qualified)
53
+ return unless date
54
+
55
+ date = date.as_interval if date.is_a? CocinaDisplay::Dates::DateRange
56
+ date.to_a.map(&:year).compact.uniq.sort
57
+ end
58
+
44
59
  # String for displaying the earliest preferred publication year or range.
45
60
  # Considers publication, creation, and capture dates in that order.
46
61
  # Prefers dates marked as primary and those with a declared encoding.
@@ -61,24 +76,28 @@ module CocinaDisplay
61
76
  # @example
62
77
  # CocinaRecord.fetch('bt553vr2845').imprint_display_str #=> "New York : Meridian Book, 1993, c1967"
63
78
  def imprint_display_str
64
- imprints.map(&:display_str).compact_blank.join("; ")
79
+ imprint_events.map(&:display_str).compact_blank.join("; ")
65
80
  end
66
81
 
67
- 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
68
88
 
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}\")" : "*"
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
75
94
 
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
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") }
82
101
  end
83
102
 
84
103
  # Array of CocinaDisplay::Imprint objects for all relevant Cocina events.
@@ -86,19 +105,22 @@ module CocinaDisplay
86
105
  # Considers event types as well as date types if the event is untyped.
87
106
  # Prefers events where the date was not encoded, if any.
88
107
  # @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)
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)
97
113
  end
98
114
 
99
115
  imprints.reject(&:date_encoding?).presence || imprints
100
116
  end
101
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
+
102
124
  # The earliest preferred publication date as a CocinaDisplay::Dates::Date object.
103
125
  # Considers publication, creation, and capture dates in that order.
104
126
  # Prefers dates marked as primary and those with a declared encoding.
@@ -106,8 +128,12 @@ module CocinaDisplay
106
128
  # @return [CocinaDisplay::Dates::Date] The earliest preferred date
107
129
  # @return [nil] if no dates are left after filtering
108
130
  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)
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)
111
137
  end.compact.first
112
138
  end
113
139