cocina_display 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +0 -1
  3. data/.standard.yml +1 -1
  4. data/README.md +21 -2
  5. data/config/i18n-tasks.yml +0 -0
  6. data/config/licenses.yml +59 -0
  7. data/config/locales/en.yml +109 -0
  8. data/lib/cocina_display/cocina_record.rb +27 -63
  9. data/lib/cocina_display/concerns/accesses.rb +78 -0
  10. data/lib/cocina_display/concerns/contributors.rb +32 -11
  11. data/lib/cocina_display/concerns/events.rb +19 -6
  12. data/lib/cocina_display/concerns/forms.rb +98 -11
  13. data/lib/cocina_display/concerns/geospatial.rb +9 -5
  14. data/lib/cocina_display/concerns/identifiers.rb +15 -4
  15. data/lib/cocina_display/concerns/languages.rb +6 -2
  16. data/lib/cocina_display/concerns/notes.rb +36 -0
  17. data/lib/cocina_display/concerns/related_resources.rb +20 -0
  18. data/lib/cocina_display/concerns/subjects.rb +25 -8
  19. data/lib/cocina_display/concerns/titles.rb +67 -25
  20. data/lib/cocina_display/concerns/{access.rb → url_helpers.rb} +3 -3
  21. data/lib/cocina_display/concerns.rb +6 -0
  22. data/lib/cocina_display/contributors/contributor.rb +47 -26
  23. data/lib/cocina_display/contributors/name.rb +18 -14
  24. data/lib/cocina_display/contributors/role.rb +20 -13
  25. data/lib/cocina_display/dates/date.rb +55 -14
  26. data/lib/cocina_display/dates/date_range.rb +0 -2
  27. data/lib/cocina_display/description/access.rb +41 -0
  28. data/lib/cocina_display/description/access_contact.rb +11 -0
  29. data/lib/cocina_display/description/url.rb +17 -0
  30. data/lib/cocina_display/display_data.rb +104 -0
  31. data/lib/cocina_display/events/event.rb +8 -4
  32. data/lib/cocina_display/events/imprint.rb +0 -10
  33. data/lib/cocina_display/events/location.rb +0 -2
  34. data/lib/cocina_display/events/note.rb +33 -0
  35. data/lib/cocina_display/forms/form.rb +71 -0
  36. data/lib/cocina_display/forms/genre.rb +12 -0
  37. data/lib/cocina_display/forms/resource_type.rb +38 -0
  38. data/lib/cocina_display/geospatial.rb +1 -1
  39. data/lib/cocina_display/identifier.rb +101 -0
  40. data/lib/cocina_display/json_backed_record.rb +27 -0
  41. data/lib/cocina_display/language.rb +9 -11
  42. data/lib/cocina_display/license.rb +32 -0
  43. data/lib/cocina_display/note.rb +103 -0
  44. data/lib/cocina_display/related_resource.rb +74 -0
  45. data/lib/cocina_display/subjects/subject.rb +32 -9
  46. data/lib/cocina_display/subjects/subject_value.rb +34 -16
  47. data/lib/cocina_display/title.rb +194 -0
  48. data/lib/cocina_display/utils.rb +4 -4
  49. data/lib/cocina_display/version.rb +1 -1
  50. data/lib/cocina_display.rb +30 -2
  51. metadata +45 -11
  52. data/lib/cocina_display/title_builder.rb +0 -397
  53. /data/lib/cocina_display/vocabularies/{marc_country_codes.rb → marc_country.rb} +0 -0
  54. /data/lib/cocina_display/vocabularies/{marc_relator_codes.rb → marc_relator.rb} +0 -0
@@ -0,0 +1,101 @@
1
+ module CocinaDisplay
2
+ # An identifier for an object or a descriptive value.
3
+ class Identifier
4
+ attr_reader :cocina
5
+
6
+ # Source URI values for common identifiers
7
+ # If you have the bare ID, you can always add it to these to make a valid URL
8
+ SOURCE_URIS = {
9
+ "ORCID" => "https://orcid.org/",
10
+ "ROR" => "https://ror.org/",
11
+ "DOI" => "https://doi.org/",
12
+ "ISNI" => "https://isni.org/"
13
+ }.freeze
14
+
15
+ # Initialize an Identifier from Cocina structured data.
16
+ # @param cocina [Hash]
17
+ def initialize(cocina)
18
+ @cocina = cocina
19
+ end
20
+
21
+ # String representation of the identifier.
22
+ # Prefers the URI representation where present.
23
+ # @return [String]
24
+ def to_s
25
+ uri || value
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(Identifier) && other.cocina == cocina
30
+ end
31
+
32
+ # The raw value from the Cocina structured data.
33
+ # Prefers the URI representation where present.
34
+ # @return [String, nil]
35
+ def value
36
+ cocina["uri"].presence || cocina["value"].presence
37
+ end
38
+
39
+ # The "identifying" part of the identifier.
40
+ # Tries to parse from the end of the URI.
41
+ # @example DOI
42
+ # 10.1234/doi
43
+ # @return [String, nil]
44
+ def identifier
45
+ URI(value).path.delete_prefix("/") if value
46
+ end
47
+
48
+ # The identifier as a URI, if available.
49
+ # Tries to construct a URI if the parts are available to do so.
50
+ # @example DOI
51
+ # https://doi.org/10.1234/doi
52
+ # @return [String, nil]
53
+ def uri
54
+ cocina["uri"].presence || ([scheme_uri.delete_suffix("/"), identifier].join("/") if scheme_uri && identifier)
55
+ end
56
+
57
+ # The type of the identifier, e.g. "DOI".
58
+ # @return [String, nil]
59
+ def type
60
+ ("DOI" if doi?) || cocina["type"].presence
61
+ end
62
+
63
+ # The declared encoding of the identifier, if any.
64
+ # @return [String, nil]
65
+ def code
66
+ cocina.dig("source", "code").presence
67
+ end
68
+
69
+ # The base URI used to resolve the identifier, if any.
70
+ # @example DOI
71
+ # https://doi.org/
72
+ # @return [String, nil]
73
+ def scheme_uri
74
+ cocina.dig("source", "uri") || SOURCE_URIS[type]
75
+ end
76
+
77
+ # Label used to render the identifier for display.
78
+ # Uses a displayLabel if available, otherwise tries to look up via type.
79
+ # Falls back to a generic label for any unknown identifier types.
80
+ # @return [String]
81
+ def label
82
+ cocina["displayLabel"].presence ||
83
+ I18n.t(label_key, default: :identifier, scope: "cocina_display.field_label.identifier")
84
+ end
85
+
86
+ # Check if the identifier is a DOI.
87
+ # There are several indicators that could suggest this.
88
+ # @return [Boolean]
89
+ def doi?
90
+ cocina["type"]&.match?(/doi/i) || code == "doi" || cocina["uri"]&.include?("://doi.org")
91
+ end
92
+
93
+ private
94
+
95
+ # Key used for i18n lookup of the label, based on the type.
96
+ # @return [String, nil]
97
+ def label_key
98
+ type&.parameterize&.underscore
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CocinaDisplay
4
+ class JsonBackedRecord
5
+ # The parsed Cocina document.
6
+ # @return [Hash]
7
+ attr_reader :cocina_doc
8
+
9
+ # Initialize a CocinaRecord with a Cocina document hash.
10
+ # @param cocina_doc [Hash]
11
+ def initialize(cocina_doc)
12
+ @cocina_doc = cocina_doc
13
+ end
14
+
15
+ # Evaluate a JSONPath expression against the Cocina document.
16
+ # @return [Enumerator] An enumerator that yields results matching the expression.
17
+ # @param path_expression [String] The JSONPath expression to evaluate.
18
+ # @see https://www.rubydoc.info/gems/janeway-jsonpath/0.6.0/file/README.md
19
+ # @example Name values for contributors
20
+ # record.path("$.description.contributor.*.name.*.value").search #=> ["Smith, John", "ACME Corp."]
21
+ # @example Filtering nodes using a condition
22
+ # record.path("$.description.contributor[?(@.type == 'person')].name.*.value").search #=> ["Smith, John"]
23
+ def path(path_expression)
24
+ Janeway.enum_for(path_expression, cocina_doc)
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,4 @@
1
- require "iso639"
2
- require_relative "vocabularies/searchworks_languages"
1
+ # frozen_string_literal: true
3
2
 
4
3
  module CocinaDisplay
5
4
  # A language associated with part or all of a Cocina object.
@@ -27,21 +26,20 @@ module CocinaDisplay
27
26
  # Decoded name of the language based on the code, if present.
28
27
  # @return [String, nil]
29
28
  def decoded_value
30
- Vocabularies::SEARCHWORKS_LANGUAGES[code] || (Iso639[code] if iso_639?)
29
+ Vocabularies::SEARCHWORKS_LANGUAGES[code] if searchworks_language?
30
+ end
31
+
32
+ # Display label for this field.
33
+ # @return [String]
34
+ def label
35
+ cocina["displayLabel"].presence || I18n.t("cocina_display.field_label.language")
31
36
  end
32
37
 
33
38
  # True if the language is recognized by Searchworks.
34
39
  # @see CocinaDisplay::Vocabularies::SEARCHWORKS_LANGUAGES
35
40
  # @return [Boolean]
36
41
  def searchworks_language?
37
- Vocabularies::SEARCHWORKS_LANGUAGES.value?(to_s)
38
- end
39
-
40
- # True if the language has a code sourced from the ISO 639 vocabulary.
41
- # @see https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
42
- # @return [Boolean]
43
- def iso_639?
44
- cocina.dig("source", "code")&.start_with? "iso639"
42
+ Vocabularies::SEARCHWORKS_LANGUAGES.value?(cocina["value"]) || Vocabularies::SEARCHWORKS_LANGUAGES.key?(code)
45
43
  end
46
44
  end
47
45
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CocinaDisplay
4
+ # A license associated with part or all of a Cocina object.
5
+ # This is the license entity used for translating a license URL into text
6
+ # for display.
7
+ class License
8
+ LICENSE_FILE_PATH = File.join(__dir__, "..", "..", "config", "licenses.yml").freeze
9
+
10
+ attr_reader :description, :uri
11
+
12
+ # Raised when the license provided is not valid
13
+ class LegacyLicenseError < StandardError; end
14
+
15
+ # A hash of license URLs to their description attributes
16
+ # @return [Hash{String => Hash{String => String}}]
17
+ def self.licenses
18
+ @licenses || YAML.safe_load(ERB.new(File.read(LICENSE_FILE_PATH)).result)
19
+ end
20
+
21
+ # Initialize a License from a license URL.
22
+ # @param url [String] The license URL.
23
+ # @raise [LegacyLicenseError] if the license URL is not in the config
24
+ def initialize(url:)
25
+ raise LegacyLicenseError unless License.licenses.key?(url)
26
+
27
+ attrs = License.licenses.fetch(url)
28
+ @uri = url
29
+ @description = attrs.fetch("description")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,103 @@
1
+ module CocinaDisplay
2
+ # A note associated with a cocina record
3
+ class Note
4
+ ABSTRACT_TYPES = ["summary", "abstract", "scope and content"].freeze
5
+ ABSTRACT_DISPLAY_LABEL_REGEX = /Abstract|Summary|Scope and content/i
6
+ PREFERRED_CITATION_TYPES = ["preferred citation"].freeze
7
+ PREFERRED_CITATION_DISPLAY_LABEL_REGEX = /Preferred citation/i
8
+ TOC_TYPES = ["table of contents"].freeze
9
+ TOC_DISPLAY_LABEL_REGEX = /Table of contents/i
10
+
11
+ attr_reader :cocina
12
+
13
+ # Initialize a Note from Cocina structured data.
14
+ # @param cocina [Hash]
15
+ def initialize(cocina)
16
+ @cocina = cocina
17
+ end
18
+
19
+ # String representation of the note.
20
+ # @return [String, nil]
21
+ def to_s
22
+ Utils.compact_and_join(values, delimiter: " -- ").presence
23
+ end
24
+
25
+ # The raw values from the Cocina data, flattened if nested.
26
+ # @return [String]
27
+ def values
28
+ Utils.flatten_nested_values(cocina).pluck("value").compact_blank
29
+ end
30
+
31
+ # The raw values from the Cocina data as a hash with type as key.
32
+ # @return [Hash{String => String}]
33
+ def values_by_type
34
+ Utils.flatten_nested_values(cocina).each_with_object({}) do |node, hash|
35
+ type = node["type"]
36
+ hash[type] ||= []
37
+ hash[type] << node["value"]
38
+ end
39
+ end
40
+
41
+ # The type of the note, e.g. "abstract".
42
+ # @return [String, nil]
43
+ def type
44
+ cocina["type"].presence
45
+ end
46
+
47
+ # The display label set in Cocina
48
+ # @return [String, nil]
49
+ def display_label
50
+ cocina["displayLabel"].presence
51
+ end
52
+
53
+ # Label used to render the note for display.
54
+ # Uses a displayLabel if available, otherwise tries to look up via type.
55
+ # Falls back to a default label derived from the type or a generic note label if
56
+ # no type is set.
57
+ # @return [String]
58
+ def label
59
+ display_label ||
60
+ I18n.t(type&.parameterize&.underscore, default: default_label, scope: "cocina_display.field_label.note")
61
+ end
62
+
63
+ # Check if the note is an abstract
64
+ # @return [Boolean]
65
+ def abstract?
66
+ display_label&.match?(ABSTRACT_DISPLAY_LABEL_REGEX) ||
67
+ ABSTRACT_TYPES.include?(type)
68
+ end
69
+
70
+ # Check if the note is a general note (not a table of contents, abstract, preferred citation, or part)
71
+ # @return [Boolean]
72
+ def general_note?
73
+ !table_of_contents? && !abstract? && !preferred_citation? && !part?
74
+ end
75
+
76
+ # Check if the note is a preferred citation
77
+ # @return [Boolean]
78
+ def preferred_citation?
79
+ display_label&.match?(PREFERRED_CITATION_DISPLAY_LABEL_REGEX) ||
80
+ PREFERRED_CITATION_TYPES.include?(type)
81
+ end
82
+
83
+ # Check if the note is a table of contents
84
+ # @return [Boolean]
85
+ def table_of_contents?
86
+ display_label&.match?(TOC_DISPLAY_LABEL_REGEX) ||
87
+ TOC_TYPES.include?(type)
88
+ end
89
+
90
+ # Check if the note is a part note
91
+ # @note These are combined with the title and not displayed separately.
92
+ # @return [Boolean]
93
+ def part?
94
+ type == "part"
95
+ end
96
+
97
+ private
98
+
99
+ def default_label
100
+ type&.capitalize || I18n.t("cocina_display.field_label.note.note")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CocinaDisplay
4
+ # A resource related to the record. See https://github.com/sul-dlss/cocina-models/blob/main/lib/cocina/models/related_resource.rb
5
+ # @note Related resources have no structural metadata.
6
+ class RelatedResource < JsonBackedRecord
7
+ include CocinaDisplay::Concerns::Accesses
8
+ include CocinaDisplay::Concerns::Events
9
+ include CocinaDisplay::Concerns::Contributors
10
+ include CocinaDisplay::Concerns::Identifiers
11
+ include CocinaDisplay::Concerns::Notes
12
+ include CocinaDisplay::Concerns::Titles
13
+ include CocinaDisplay::Concerns::UrlHelpers
14
+ include CocinaDisplay::Concerns::Subjects
15
+ include CocinaDisplay::Concerns::Forms
16
+ include CocinaDisplay::Concerns::Languages
17
+ include CocinaDisplay::Concerns::Geospatial
18
+
19
+ # Description of the relation to the source record.
20
+ # @return [String]
21
+ # @example "is part of"
22
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#relatedresource-types
23
+ attr_reader :type
24
+
25
+ # Restructure the hash so that everything is under "description" key, since
26
+ # it's all descriptive metadata. This makes most CocinaRecord methods work.
27
+ def initialize(cocina_doc)
28
+ @type = cocina_doc["type"]
29
+ super({"description" => cocina_doc.except("type")})
30
+ end
31
+
32
+ # Label used to group the related resource for display.
33
+ # @return [String]
34
+ def label
35
+ cocina_doc.dig("description", "displayLabel").presence || type_label
36
+ end
37
+
38
+ # String representation of the related resource.
39
+ # @return [String, nil]
40
+ def to_s
41
+ main_title || url
42
+ end
43
+
44
+ # URL to the related resource for link construction.
45
+ # If there are multiple URLs, uses the first.
46
+ # @return [String, nil]
47
+ def url
48
+ urls.first&.to_s || purl_url
49
+ end
50
+
51
+ # Is this a related resource with a URL?
52
+ # @return [Boolean]
53
+ def url?
54
+ url.present?
55
+ end
56
+
57
+ # Nested display data for the related resource.
58
+ # Combines titles, contributors, notes, and access information.
59
+ # @note Used for extended display of citations, e.g. on hp566jq8781.
60
+ # @return [Array<DisplayData>]
61
+ def display_data
62
+ title_display_data + contributor_display_data + general_note_display_data + preferred_citation_display_data + access_display_data
63
+ end
64
+
65
+ private
66
+
67
+ # Key used for i18n lookup of the label, based on the type.
68
+ # Falls back to a generic label for any unknown types.
69
+ # @return [String]
70
+ def type_label
71
+ I18n.t(type&.parameterize&.underscore, default: :related_to, scope: "cocina_display.field_label.related_resource")
72
+ end
73
+ end
74
+ end
@@ -1,6 +1,3 @@
1
- require_relative "../utils"
2
- require_relative "subject_value"
3
-
4
1
  module CocinaDisplay
5
2
  module Subjects
6
3
  # Base class for subjects in Cocina structured data.
@@ -27,23 +24,49 @@ module CocinaDisplay
27
24
  subject_values.map(&:to_s).compact_blank
28
25
  end
29
26
 
30
- # A string representation of the entire subject, concatenated for display.
27
+ # The value to use for display.
28
+ # Genre values are capitalized; other subject values are not.
31
29
  # @return [String]
32
30
  def to_s
31
+ (type == "genre") ? display_value&.upcase_first : display_value
32
+ end
33
+
34
+ # A string representation of the entire subject, concatenated for display.
35
+ # @return [String]
36
+ def display_value
33
37
  Utils.compact_and_join(display_values, delimiter: " > ")
34
38
  end
35
39
 
40
+ # Label used to render the subject for display.
41
+ # Uses a displayLabel if available, otherwise looks up via type.
42
+ # @return [String]
43
+ def label
44
+ cocina["displayLabel"].presence || type_label
45
+ end
46
+
36
47
  # Individual values composing this subject.
37
48
  # Can be multiple if the Cocina featured nested data.
38
- # If no type was specified on a value, uses the top-level subject type.
49
+ # All SubjectValues inherit the type of their parent Subject.
39
50
  # @return [Array<SubjectValue>]
40
51
  def subject_values
41
- @subject_values ||= Utils.flatten_nested_values(cocina, atomic_types: SubjectValue.atomic_types).map do |value|
42
- subject_value = SubjectValue.from_cocina(value)
43
- subject_value.type ||= type
44
- subject_value
52
+ @subject_values ||= (Array(cocina["parallelValue"]).presence || [cocina]).flat_map do |node|
53
+ if SubjectValue.atomic_types.include?(type)
54
+ SubjectValue.from_cocina(node, type: type)
55
+ else
56
+ Utils.flatten_nested_values(node, atomic_types: SubjectValue.atomic_types).flat_map do |value|
57
+ SubjectValue.from_cocina(value, type: value["type"] || type)
58
+ end
59
+ end
45
60
  end
46
61
  end
62
+
63
+ private
64
+
65
+ # Type-specific label for this subject.
66
+ # @return [String]
67
+ def type_label
68
+ I18n.t(type&.parameterize&.underscore, default: :subject, scope: "cocina_display.field_label.subject")
69
+ end
47
70
  end
48
71
  end
49
72
  end
@@ -1,11 +1,3 @@
1
- require "geo/coord"
2
-
3
- require_relative "subject"
4
- require_relative "../contributors/name"
5
- require_relative "../title_builder"
6
- require_relative "../dates/date"
7
- require_relative "../geospatial"
8
-
9
1
  module CocinaDisplay
10
2
  module Subjects
11
3
  # A descriptive value that can be part of a Subject.
@@ -16,11 +8,29 @@ module CocinaDisplay
16
8
  # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#subject-part-types-for-structured-value
17
9
  attr_accessor :type
18
10
 
19
- # Create a SubjectValue from Cocina structured data.
11
+ # Create SubjectValues from Cocina structured data.
12
+ # Pre-coordinated string values will be split into multiple SubjectValues.
20
13
  # @param cocina [Hash] The Cocina structured data for the subject.
21
- # @return [SubjectValue]
22
- def self.from_cocina(cocina)
23
- SUBJECT_VALUE_TYPES.fetch(cocina["type"], SubjectValue).new(cocina)
14
+ # @param type [String, nil] The type, coming from the parent Subject.
15
+ # @return [Array<SubjectValue>]
16
+ def self.from_cocina(cocina, type:)
17
+ split_pre_coordinated_values(cocina, type: type).map do |value|
18
+ SUBJECT_VALUE_TYPES.fetch(type, SubjectValue).new(value).tap do |obj|
19
+ obj.type ||= type
20
+ end
21
+ end
22
+ end
23
+
24
+ # Split a pre-coordinated subject value joined with "--" into multiple values.
25
+ # Ignores the "--" string for coordinate subject types, which use it differently.
26
+ # @param cocina [Hash] The Cocina structured data for the subject.
27
+ # @return [Array<Hash>] An array of Cocina hashes, one for each split value
28
+ def self.split_pre_coordinated_values(cocina, type:)
29
+ if cocina["value"].is_a?(String) && cocina["value"].include?("--") && !type.include?("coordinates")
30
+ cocina["value"].split("--").map { |v| cocina.merge("value" => v.strip) }
31
+ else
32
+ [cocina]
33
+ end
24
34
  end
25
35
 
26
36
  # All subject value types that should not be further destructured.
@@ -65,12 +75,20 @@ module CocinaDisplay
65
75
 
66
76
  # A subject value representing an entity with a title.
67
77
  class TitleSubjectValue < SubjectValue
78
+ attr_reader :title
79
+
80
+ # Initialize a TitleSubjectValue object with Cocina structured data.
81
+ # @param cocina [Hash] The Cocina structured data for the subject.
82
+ def initialize(cocina)
83
+ super
84
+ @title = Title.new(cocina)
85
+ end
86
+
68
87
  # Construct a title string to use for display.
69
- # @see CocinaDisplay::TitleBuilder.build
70
- # @note Unclear how often structured title subjects occur "in the wild".
71
- # @return [String]
88
+ # @see CocinaDisplay::Title#to_s
89
+ # @return [String, nil]
72
90
  def to_s
73
- TitleBuilder.build([cocina])
91
+ title.to_s
74
92
  end
75
93
  end
76
94