cocina_display 1.7.0 → 1.8.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: 46cfe681a820ad5986d0503157b5add9a8cb089af31e3afaba0ed2964d33bda6
4
- data.tar.gz: b60af2ecc9cfdc031e57ae98cb1093aa3b111a5c14dda40b71814e07a9f2d941
3
+ metadata.gz: b326b540fee8c0ad46a66aa4db50c2857328b08c42a57b0777b3f2e4ccc2a684
4
+ data.tar.gz: e8c17b5bcee9af19b32c98a29d60424ee693131f703936dbe10c91fd9a67caa6
5
5
  SHA512:
6
- metadata.gz: 2c010edefb699c21c82a3c426befcb16e4fa0e72aeeda6e77c2ed8eba9fa1aa6e365e889fda6f94f6d118a3bde2a968e4ece610f918995721c14a1fc6fc5b723
7
- data.tar.gz: fa61cbcd25787c2ec552fff17f5282e425ff5b46d03f0b60476c017faab41f7f059bd7630e54c2fc753a6330cf87a41c12bda20fbd3bd602e567264cfa6b133b
6
+ metadata.gz: aea3773bf3e3aa17c1e150a33da7c906f178a1814cc8c911fff1dd2cc5d08c5969970d9d68ecb07c72e29b4692654935f66b44f4445ea239ec437c5ab1e98017
7
+ data.tar.gz: 409b98e5c82a5456cc137e07f5786ab2d5118ab267b5667938e0e1be9896a116b416a2bc73f077d61c4c434f83f1dc3a4f59abf05eeeb1ea3ec6195efd45aa09
@@ -108,11 +108,13 @@ module CocinaDisplay
108
108
  end
109
109
 
110
110
  # All contributors for the object, including authors, editors, etc.
111
- # @note Does not include contributors attached to events.
111
+ # Checks both description.contributor and description.event.contributor.
112
112
  # @return [Array<Contributor>]
113
113
  def contributors
114
- @contributors ||= path("$.description.contributor.*")
115
- .map { |c| CocinaDisplay::Contributors::Contributor.new(c) }
114
+ @contributors ||= Enumerator::Chain.new(
115
+ path("$.description.contributor.*"),
116
+ path("$.description.event.*.contributor.*")
117
+ ).map { |c| CocinaDisplay::Contributors::Contributor.new(c) }
116
118
  end
117
119
 
118
120
  # All contributors with an "author" role.
@@ -145,7 +147,13 @@ module CocinaDisplay
145
147
  # @return [Array<Contributor>]
146
148
  def additional_contributors
147
149
  return [] if contributors.empty? || contributors.one?
148
- contributors - [main_contributor]
150
+ contributors.reject { |c| imprint_contributors.include?(c) } - [main_contributor]
151
+ end
152
+
153
+ # The contributors associated with imprint events (usually publishers).
154
+ # @return [Array<Contributor>]
155
+ def imprint_contributors
156
+ imprint_events.flat_map(&:contributors).uniq
149
157
  end
150
158
  end
151
159
  end
@@ -38,11 +38,10 @@ module CocinaDisplay
38
38
  # @param ignore_qualified [Boolean] Reject qualified dates (e.g. approximate)
39
39
  # @return [Array<Integer>, nil]
40
40
  # @note 6 BCE will appear as -5; 4 CE will appear as 4.
41
- def pub_year_int_range(ignore_qualified: false)
41
+ def pub_year_ints(ignore_qualified: false)
42
42
  date = pub_date(ignore_qualified: ignore_qualified)
43
43
  return unless date
44
44
 
45
- date = date.as_interval if date.is_a? CocinaDisplay::Dates::DateRange
46
45
  date.to_a.map(&:year).compact.uniq.sort
47
46
  end
48
47
 
@@ -199,15 +199,13 @@ module CocinaDisplay
199
199
  when "manuscript", "mixed material"
200
200
  values << "Archive/Manuscript"
201
201
  when "moving image"
202
- values << "Video"
202
+ values << "Video/Film"
203
203
  when "notated music"
204
204
  values << "Music score"
205
205
  when "software, multimedia"
206
206
  # Prevent GIS datasets from being labeled as "Software"
207
207
  values << "Software/Multimedia" unless cartographic? || dataset?
208
- when "sound recording-musical"
209
- values << "Music recording"
210
- when "sound recording-nonmusical", "sound recording"
208
+ when "sound recording-musical", "sound recording-nonmusical", "sound recording"
211
209
  values << "Sound recording"
212
210
  when "still image"
213
211
  values << "Image"
@@ -216,7 +214,8 @@ module CocinaDisplay
216
214
  # 2 records currently (2025) in Searchworks do this, but it is real.
217
215
  if periodical? || archived_website?
218
216
  values << "Journal/Periodical" if periodical?
219
- values << "Archived website" if archived_website?
217
+ values << "Website" if archived_website?
218
+ values << "Website|Archived website" if archived_website?
220
219
  else
221
220
  values << "Book"
222
221
  end
@@ -8,8 +8,7 @@ module CocinaDisplay
8
8
  # @example
9
9
  # record.druid #=> "druid:bb099mt5053"
10
10
  def druid
11
- cocina_doc["externalIdentifier"] ||
12
- cocina_doc.dig("description", "purl")&.split("/")&.last
11
+ cocina_doc["externalIdentifier"] || purl_url&.split("/")&.last
13
12
  end
14
13
 
15
14
  # The DRUID for the object, without the +druid:+ prefix.
@@ -64,10 +63,12 @@ module CocinaDisplay
64
63
  folio_hrid || bare_druid
65
64
  end
66
65
 
67
- # Identifier objects extracted from the Cocina metadata.
66
+ # All identifier objects, optionally filtered by type.
67
+ # @param type [String, nil] The type of identifier to filter by (e.g. "DOI").
68
+ # @note Type matching is case insensitive.
68
69
  # @return [Array<Identifier>]
69
- def identifiers
70
- @identifiers ||= path("$.description.identifier[*]").map { |id| Identifier.new(id) } + Array(doi_from_identification)
70
+ def identifiers(type: nil)
71
+ type.present? ? all_identifiers.filter { |id| id.type&.casecmp?(type) } : all_identifiers
71
72
  end
72
73
 
73
74
  # Labelled display data for identifiers.
@@ -78,6 +79,12 @@ module CocinaDisplay
78
79
 
79
80
  private
80
81
 
82
+ # All identifier objects extracted from the Cocina metadata.
83
+ # @return [Array<Identifier>]
84
+ def all_identifiers
85
+ @identifiers ||= path("$.description.identifier[*]").map { |id| Identifier.new(id) } + Array(doi_from_identification)
86
+ end
87
+
81
88
  # Synthetic Identifier object for a DOI in the identification block.
82
89
  # @return [Array<Identifier>]
83
90
  def doi_from_identification
@@ -8,6 +8,25 @@ module CocinaDisplay
8
8
  @notes ||= path("$.description.note.*").map { |note| CocinaDisplay::Note.new(note) }
9
9
  end
10
10
 
11
+ # Text of all abstract notes.
12
+ # @return [Array<String>]
13
+ def abstracts
14
+ notes.select(&:abstract?).map(&:to_s).compact_blank
15
+ end
16
+
17
+ # Text of all table of contents notes.
18
+ # @return [Array<String>]
19
+ def tables_of_contents
20
+ notes.select(&:table_of_contents?).map(&:to_s).compact_blank
21
+ end
22
+
23
+ # Preferred citation for the object.
24
+ # If there are multiple notes with this type, uses the first.
25
+ # @return [String, nil]
26
+ def preferred_citation
27
+ notes.find(&:preferred_citation?)&.to_s
28
+ end
29
+
11
30
  # Abstract metadata for display.
12
31
  # @return [Array<CocinaDisplay::DisplayData>]
13
32
  def abstract_display_data
@@ -4,23 +4,44 @@ module CocinaDisplay
4
4
  module Concerns
5
5
  # Methods for inspecting structural metadata (e.g. file hierarchy)
6
6
  module Structural
7
+ # Structured data for all file sets in the object.
8
+ # Each fileset contains one or more files.
9
+ # @return [Array<CocinaDisplay::Structural::FileSet>]
10
+ # @example
11
+ # record.filesets.each do |fileset|
12
+ # puts fileset.type #=> "image"
13
+ # puts fileset.label #=> "High Resolution Images"
14
+ # end
15
+ def filesets
16
+ @filesets ||= path("$.structural.contains.*").map do |fileset|
17
+ CocinaDisplay::Structural::FileSet.new(fileset, druid: bare_druid, base_url: stacks_base_url)
18
+ end
19
+ end
20
+
7
21
  # Structured data for all individual files in the object.
8
22
  # Traverses nested FileSet structure to return a flattened array.
9
- # @return [Array<Hash>]
23
+ # @return [Array<CocinaDisplay::Structural::File>]
10
24
  # @example
11
25
  # record.files.each do |file|
12
- # puts file["filename"] #=> "image1.jpg"
13
- # puts file["size"] #=> 123456
26
+ # puts file.filename #=> "image1.jpg"
27
+ # puts file.size #=> 123456
14
28
  # end
15
29
  def files
16
- @files ||= path("$.structural.contains.*.structural.contains.*").search
30
+ filesets.flat_map(&:files)
17
31
  end
18
32
 
19
33
  # All unique MIME types of files in this object.
20
34
  # @return [Array<String>]
21
35
  # @example ["image/jpeg", "application/pdf"]
22
36
  def file_mime_types
23
- files.pluck("hasMimeType").uniq
37
+ files.map(&:mime_type).compact.uniq
38
+ end
39
+
40
+ # All unique types of filesets in this object.
41
+ # @return [Array<String>]
42
+ # @example ["image", "document"]
43
+ def fileset_types
44
+ filesets.map(&:type).compact.uniq
24
45
  end
25
46
 
26
47
  # Human-readable string representation of {total_file_size_int}.
@@ -34,7 +55,25 @@ module CocinaDisplay
34
55
  # @return [Integer]
35
56
  # @example 2621440
36
57
  def total_file_size_int
37
- files.pluck("size").sum
58
+ files.map(&:size).compact.sum
59
+ end
60
+
61
+ # URL to a thumbnail image for this object, if any.
62
+ # @note Uses the IIIF image server to generate an image of the given size.
63
+ # @param region [String] Desired region of the image (e.g., "full", "square", "x,y,w,h", "pct:x,y,w,h").
64
+ # @param width [String] Desired width of the image in pixels (use "!" prefix to preserve aspect ratio).
65
+ # @param height [String] Desired height of the image in pixels.
66
+ # @return [String, nil]
67
+ # @example "https://stacks.stanford.edu/image/iiif/ts786ny5936%2FPC0170_s1_E_0204.jp2/full/!400,400/0/default.jpg"
68
+ def thumbnail_url(region: "full", width: "!400", height: "400")
69
+ thumbnail_file&.iiif_url(region: region, width: width, height: height)
70
+ end
71
+
72
+ # True if the object has a usable thumbnail file.
73
+ # @note Does not attempt to crawl virtual object members for thumbnails.
74
+ # @return [Boolean]
75
+ def thumbnail?
76
+ thumbnail_file.present?
38
77
  end
39
78
 
40
79
  # DRUIDs of collections this object is a member of.
@@ -44,17 +83,36 @@ module CocinaDisplay
44
83
  path("$.structural.isMemberOf.*").map { |druid| druid.delete_prefix("druid:") }
45
84
  end
46
85
 
86
+ # Whether this object is a virtual object.
87
+ # @return [Boolean]
47
88
  def virtual_object?
48
- return false if path("$.structural.contains.*").any?
89
+ return false if filesets.any?
49
90
 
50
91
  path("$.structural.hasMemberOrders.*.members.*").any?
51
92
  end
52
93
 
94
+ # DRUIDs of members of this virtual object.
95
+ # @return [Array<String>]
96
+ # @example ["ts786ny5936", "tp006ms8736", "tj297ys4758"]
53
97
  def virtual_object_members
54
98
  return [] unless virtual_object?
55
99
 
56
100
  path("$.structural.hasMemberOrders.*.members.*").map { |druid| druid.delete_prefix("druid:") }
57
101
  end
102
+
103
+ # DRUIDs of virtual objects this object is a part of.
104
+ # @return [Array<String>]
105
+ # @example "hj097bm8879"
106
+ def virtual_object_parents
107
+ related_resources.filter { |res| res.type == "part of" }.map(&:druid).compact_blank
108
+ end
109
+
110
+ # The thumbnail file for this object, if any.
111
+ # Prefers files marked as thumbnails; falls back to any JP2 image.
112
+ # @return [CocinaDisplay::Structural::File, nil]
113
+ def thumbnail_file
114
+ files.find(&:thumbnail?) || files.find(&:jp2_image?)
115
+ end
58
116
  end
59
117
  end
60
118
  end
@@ -84,7 +84,7 @@ module CocinaDisplay
84
84
  return
85
85
  end
86
86
 
87
- sanitized = value.gsub(/^[\[]+/, "").gsub(/[\.\]]+$/, "")
87
+ sanitized = value.gsub(/^\[+/, "").gsub(/[.\]]+$/, "")
88
88
  sanitized = value.rjust(4, "0") if /^\d{3}$/.match?(value)
89
89
 
90
90
  sanitized
@@ -291,25 +291,17 @@ module CocinaDisplay
291
291
  return value.sub(/(\d{2})(\d{2})-(\d{2})/, '\1\2-\1\3')
292
292
  end
293
293
 
294
- value.gsub(/(?<![\d])(\d{1,3})([xu-]{1,3})/i) { "#{Regexp.last_match(1)}#{"0" * Regexp.last_match(2).length}" }.scan(/[\d-]/).join
294
+ value.gsub(/(?<!\d)(\d{1,3})([xu-]{1,3})/i) { "#{Regexp.last_match(1)}#{"0" * Regexp.last_match(2).length}" }.scan(/[\d-]/).join
295
295
  end
296
296
 
297
297
  # Decoded version of the date with "BCE" or "CE". Strips leading zeroes.
298
298
  # @param allowed_precisions [Array<Symbol>] List of allowed precisions for the output.
299
299
  # Defaults to [:day, :month, :year, :decade, :century, :unknown].
300
- # @param ignore_unparseable [Boolean] Return nil instead of the original value if it couldn't be parsed
301
- # @param display_original_value [Boolean] Return the original value if it was not encoded
302
300
  # @return [String]
303
- def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown], ignore_unparseable: false, display_original_value: true)
304
- return if ignore_unparseable && !parsed_date?
305
- return value.strip unless parsed_date?
306
-
307
- if display_original_value
308
- unless encoding?
309
- return value.strip unless value =~ /^-?\d+$/ || value =~ /^[\dXxu?-]{4}$/
310
- end
301
+ def decoded_value(allowed_precisions: [:day, :month, :year, :decade, :century, :unknown])
302
+ if !parsed_date? || (!encoding? && value !~ /^-?\d+$/ && value !~ /^[\dXxu?-]{4}$/)
303
+ return value.strip
311
304
  end
312
-
313
305
  if date.is_a?(EDTF::Interval)
314
306
  range = [
315
307
  Date.format_date(date.min, date.min.precision, allowed_precisions),
@@ -342,18 +334,25 @@ module CocinaDisplay
342
334
  format(qualified_format, decoded_value)
343
335
  end
344
336
 
345
- # Range between earliest possible date and latest possible date.
346
- # @note Some encodings support disjoint sets of ranges, so this method could be less accurate than {#to_a}.
347
- # @return [Range]
337
+ # Range of {Date}s between earliest possible date and latest possible date.
338
+ # @note Output has day precision, using the first day/month if unspecified.
339
+ # @note If the range is open-ended, uses today's date as the end date.
340
+ # @note {EDTF::Set}s can be disjoint ranges, but this method will return the full span, unlike {#to_a}.
341
+ # @return [Range<Date>, nil]
348
342
  def as_range
349
- return unless earliest_date && latest_date
343
+ return unless earliest_date || latest_date
350
344
 
351
- earliest_date..latest_date
345
+ start = earliest_date || latest_date
346
+ stop = latest_date || ::Date.today
347
+
348
+ start..stop
352
349
  end
353
350
 
354
- # Array of all dates that fall into the range of possible dates in the data.
355
- # @note Some encodings support disjoint sets of ranges, so this method could be more accurate than {#as_range}.
356
- # @return [Array]
351
+ # Array of all individual {Date}s that are described by the data.
352
+ # @note Output dates will have the same precision as the input date (e.g. year vs day).
353
+ # @note If the range is open-ended, uses today's date as the end date.
354
+ # @note {EDTF::Set}s can be disjoint ranges; unlike {#as_range} this method will respect any gaps.
355
+ # @return [Array<Date>]
357
356
  def to_a
358
357
  case date
359
358
  when EDTF::Set
@@ -363,8 +362,6 @@ module CocinaDisplay
363
362
  end
364
363
  end
365
364
 
366
- private
367
-
368
365
  class << self
369
366
  # Returns the date in the format specified by the precision.
370
367
  # Supports e.g. retrieving year precision when the actual date is more precise.
@@ -444,6 +441,8 @@ module CocinaDisplay
444
441
  end
445
442
  end
446
443
 
444
+ private
445
+
447
446
  # Expand placeholders like "19XX" into an object representing the full range.
448
447
  # @note This is different from dates with an explicit start/end in the Cocina.
449
448
  # @see CocinaDisplay::Dates::DateRange
@@ -523,13 +522,11 @@ module CocinaDisplay
523
522
  # MARC date parser; similar to EDTF but with some MARC-specific encodings.
524
523
  class MarcFormat < Date
525
524
  def self.normalize_to_edtf(value)
526
- return nil if value == "9999" || value == "uuuu" || value == "||||"
525
+ return nil if value == "9999" || value == "||||"
527
526
 
528
527
  super
529
528
  end
530
529
 
531
- private
532
-
533
530
  def earliest_date
534
531
  if value == "1uuu"
535
532
  ::Date.parse("1000-01-01")
@@ -545,6 +542,8 @@ module CocinaDisplay
545
542
  super
546
543
  end
547
544
  end
545
+
546
+ private
548
547
  end
549
548
 
550
549
  # Base class for date formats that match using a regex.
@@ -600,7 +599,7 @@ module CocinaDisplay
600
599
 
601
600
  # Extractor for dates encoded as Roman numerals.
602
601
  class RomanNumeralYearFormat < ExtractorDateFormat
603
- REGEX = /(?<![A-Za-z\.])(?<year>[MCDLXVI\.]+)(?![A-Za-z])/
602
+ REGEX = /(?<![A-Za-z.])(?<year>[MCDLXVI.]+)(?![A-Za-z])/
604
603
 
605
604
  def self.normalize_to_edtf(text)
606
605
  matches = text.match(REGEX)
@@ -14,8 +14,8 @@ module CocinaDisplay
14
14
  # Create the individual dates; if no encoding/type declared give them
15
15
  # top-level encoding/type
16
16
  dates = cocina["structuredValue"].map do |sv|
17
+ sv["encoding"] ||= cocina["encoding"]
17
18
  date = Date.from_cocina(sv)
18
- date.encoding ||= cocina.dig("encoding", "code")
19
19
  date.type ||= cocina["type"]
20
20
  date
21
21
  end
@@ -137,6 +137,20 @@ module CocinaDisplay
137
137
  end
138
138
  end
139
139
 
140
+ # Earliest possible date encoded in data, respecting unspecified/imprecise info.
141
+ # @return [Date]
142
+ # @return [nil] if open start
143
+ def earliest_date
144
+ start&.earliest_date
145
+ end
146
+
147
+ # Latest possible date encoded in data, respecting unspecified/imprecise info.
148
+ # @return [Date]
149
+ # @return [nil] if open-ended range
150
+ def latest_date
151
+ stop&.latest_date
152
+ end
153
+
140
154
  # Express the range as an EDTF::Interval between the start and stop dates.
141
155
  # @return [EDTF::Interval]
142
156
  def as_interval
@@ -144,6 +158,20 @@ module CocinaDisplay
144
158
  interval_stop = stop&.date&.edtf || "open"
145
159
  ::Date.edtf("#{interval_start}/#{interval_stop}")
146
160
  end
161
+
162
+ # Array of all individual {Date}s that are described by the data.
163
+ # @note Output dates will have the same precision as the input date (e.g. year vs day).
164
+ # @note {EDTF::Set}s can be disjoint ranges; unlike {#as_range} this method will respect any gaps.
165
+ # @return [Array<Date>]
166
+ def to_a
167
+ start_dates = start&.to_a || []
168
+ stop_dates = stop&.to_a || []
169
+
170
+ return [] if start_dates.empty? && stop_dates.empty?
171
+ return as_range.to_a if start_dates.one? && stop_dates.one? || stop_dates.empty?
172
+
173
+ [start_dates, stop_dates].flatten.sort.uniq
174
+ end
147
175
  end
148
176
  end
149
177
  end
@@ -2,10 +2,19 @@ module CocinaDisplay
2
2
  module Forms
3
3
  # A Resource Type form associated with part or all of a Cocina object.
4
4
  class ResourceType < Form
5
- # Resource types are lowercased for display.
5
+ # Resource types are lowercased for display, except self-deposit types.
6
6
  # @return [String]
7
7
  def to_s
8
- super&.downcase
8
+ stanford_self_deposit? ? flat_value : flat_value.downcase
9
+ end
10
+
11
+ # For self-deposit resource types, the flat value comprises primary and any subtypes.
12
+ # @return [String]
13
+ def flat_value
14
+ return super unless stanford_self_deposit?
15
+ return primary_type unless subtypes.any?
16
+
17
+ "#{primary_type} (#{subtypes.join(", ")})"
9
18
  end
10
19
 
11
20
  # Is this a Stanford self-deposit resource type?
@@ -33,6 +42,30 @@ module CocinaDisplay
33
42
  def type_label
34
43
  (I18n.t("cocina_display.field_label.form.genre") if stanford_self_deposit?) || super
35
44
  end
45
+
46
+ # The primary type, if this is a structured self-deposit resource type.
47
+ # @return [String, nil]
48
+ def primary_type
49
+ type_components["type"].first
50
+ end
51
+
52
+ # The subtypes, if this is a structured self-deposit resource type.
53
+ # @return [Array<String>]
54
+ def subtypes
55
+ type_components["subtype"] || []
56
+ end
57
+
58
+ # A hash containing the destructured resource type and subtypes, if any.
59
+ # @return [Hash<String, Array<String>>]
60
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#form-part-types-for-structured-value
61
+ # @note Only used by self-deposit resource types.
62
+ def type_components
63
+ Utils.flatten_nested_values(cocina).each_with_object({}) do |node, hash|
64
+ type = node["type"]
65
+ hash[type] ||= []
66
+ hash[type] << node["value"]
67
+ end.compact_blank
68
+ end
36
69
  end
37
70
  end
38
71
  end
@@ -38,7 +38,7 @@ module CocinaDisplay
38
38
  # @return [Coordinates, nil]
39
39
  def parse(value)
40
40
  # Remove all whitespace for easier matching/parsing
41
- match_str = value.gsub(/[\s]+/, "")
41
+ match_str = value.gsub(/\s+/, "")
42
42
 
43
43
  # Try each parser in order until one matches; bail out if none do
44
44
  parser_class = [
@@ -310,12 +310,14 @@ module CocinaDisplay
310
310
  # Parse for decimal degree points, like "41.891797, 12.486419".
311
311
  class DecimalPointParser < PointParser
312
312
  include DecimalParser
313
- PATTERN = /(?<lat>[0-9.EW\+\-]+),(?<lng>[0-9.NS\+\-]+)/
313
+
314
+ PATTERN = /(?<lat>[0-9.EW+-]+),(?<lng>[0-9.NS+-]+)/
314
315
  end
315
316
 
316
317
  # Parser for DMS-format points, like "N34°03′08″ W118°14′37″".
317
318
  class DMSPointParser < PointParser
318
319
  include DMSParser
320
+
319
321
  PATTERN = /(?<lat>[^EW]+)(?<lng>[^NS]+)/
320
322
  end
321
323
 
@@ -324,6 +326,7 @@ module CocinaDisplay
324
326
  # @see https://www.oclc.org/bibformats/en/2xx/255.html#subfieldc
325
327
  class DMSBoundingBoxParser < BoundingBoxParser
326
328
  include DMSParser
329
+
327
330
  PATTERN = /(?<min_lng>.+?)-+(?<max_lng>.+)\/(?<max_lat>.+?)-+(?<min_lat>.+)/
328
331
  end
329
332
 
@@ -331,6 +334,7 @@ module CocinaDisplay
331
334
  # @example W 126.04--W 052.03/N 050.37--N 006.8
332
335
  class DecimalBoundingBoxParser < BoundingBoxParser
333
336
  include DecimalParser
337
+
334
338
  PATTERN = /(?<min_lng>[0-9.EW]+?)-+(?<max_lng>[0-9.EW]+)\/(?<max_lat>[0-9.NS]+?)-+(?<min_lat>[0-9.NS]+)/
335
339
  end
336
340
 
@@ -8,7 +8,10 @@ module CocinaDisplay
8
8
  attr_reader :cocina_doc
9
9
 
10
10
  # Initialize a record with a Cocina document hash.
11
- # @param cocina_doc [Hash]
11
+ # @param cocina_doc [Hash<String, Object>] The Cocina document hash
12
+ # @example Initialize a record with a Cocina document
13
+ # cocina = Cocina.find(id)
14
+ # record = CocinaDisplay::CocinaDisplay.new(cocina.as_json)
12
15
  def initialize(cocina_doc)
13
16
  @cocina_doc = cocina_doc
14
17
  end
@@ -0,0 +1,134 @@
1
+ module CocinaDisplay
2
+ module Structural
3
+ # Represents a single file in a Cocina object.
4
+ class File
5
+ # Underlying hash parsed from Cocina JSON.
6
+ attr_reader :cocina
7
+
8
+ # URL to Stacks environment that will serve this file.
9
+ attr_reader :base_url
10
+
11
+ # Initialize the File with Cocina file data.
12
+ # @param cocina [Hash] Cocina structured data for a single file
13
+ # @param druid [String, nil] DRUID of the object this file belongs to
14
+ # @note Staging objects can't infer their DRUID and need it passed in explicitly.
15
+ def initialize(cocina, base_url: "https://stacks.stanford.edu", druid: nil)
16
+ @cocina = cocina
17
+ @base_url = base_url
18
+ @druid = druid
19
+ end
20
+
21
+ # The name of the file on disk, including file extension.
22
+ # @return [String, nil]
23
+ # @example "bc798xr9549_30C_Kalsang_Yulgial_thumb.jp2"
24
+ def filename
25
+ cocina["filename"]
26
+ end
27
+
28
+ # The MIME type of the file.
29
+ # @return [String, nil]
30
+ # @example "image/jp2"
31
+ def mime_type
32
+ cocina["hasMimeType"]
33
+ end
34
+
35
+ # The relation of the file to the object.
36
+ # @return [String, nil]
37
+ # @example "thumbnail"
38
+ def use
39
+ cocina["use"]
40
+ end
41
+
42
+ # The size in bytes of the file.
43
+ # @return [Integer, nil]
44
+ # @example 204800
45
+ def size
46
+ cocina["size"]
47
+ end
48
+
49
+ # True if this file was marked as a thumbnail and has nonzero dimensions.
50
+ # @return [Boolean]
51
+ def thumbnail?
52
+ use == "thumbnail" && nonzero_dimensions?
53
+ end
54
+
55
+ # True if this file is a JP2 image and has nonzero dimensions.
56
+ # @return [Boolean]
57
+ def jp2_image?
58
+ mime_type == "image/jp2" && nonzero_dimensions?
59
+ end
60
+
61
+ # True if file is an image with nonzero height and width.
62
+ # @return [Boolean]
63
+ def nonzero_dimensions?
64
+ height&.positive? && width&.positive?
65
+ end
66
+
67
+ # The height of the image in pixels, if applicable.
68
+ # @return [Integer, nil]
69
+ def height
70
+ cocina.dig("presentation", "height").to_i
71
+ end
72
+
73
+ # The width of the image in pixels, if applicable.
74
+ # @return [Integer, nil]
75
+ def width
76
+ cocina.dig("presentation", "width").to_i
77
+ end
78
+
79
+ # Generate a IIIF image URL for this file.
80
+ # @param region [String] Desired region of the image (e.g., "full", "square", "x,y,w,h", "pct:x,y,w,h").
81
+ # @param width [String] Desired width of the image in pixels (use "!" prefix to preserve aspect ratio).
82
+ # @param height [String] Desired height of the image in pixels.
83
+ # @return [String, nil]
84
+ # @example "https://stacks.stanford.edu/image/iiif/ts786ny5936%2FPC0170_s1_E_0204.jp2/full/!400,400/0/default.jpg"
85
+ def iiif_url(region: "full", width: "!400", height: "400")
86
+ return unless iiif_id.present?
87
+
88
+ "#{base_url}/image/iiif/#{iiif_id}/#{region}/#{width},#{height}/0/default.jpg"
89
+ end
90
+
91
+ # For images served over IIIF, we use the encoded file ID minus the extension.
92
+ # @return [String, nil]
93
+ # @example "ts786ny5936%2FPC0170_s1_E_0204"
94
+ def iiif_id
95
+ ERB::Util.url_encode(file_id.delete_suffix(".jp2")) if file_id.present? && jp2_image?
96
+ end
97
+
98
+ # Generate a download URL for this file from stacks.
99
+ # @return [String, nil]
100
+ def download_url
101
+ return unless file_id.present?
102
+
103
+ "#{base_url}/file/druid:#{file_id}"
104
+ end
105
+
106
+ private
107
+
108
+ # External identifier for the file, minus the URL prefix.
109
+ # @return [String, nil]
110
+ # @note Staging and production formats differ.
111
+ # @example production
112
+ # "fn851zf9475-fn851zf9475_1/fn851zf9475_00_0001.jp2"
113
+ # @example staging
114
+ # "ddbd323d-0dd9-4f14-ba72-336c2bccfb29"
115
+ def external_id
116
+ cocina["externalIdentifier"]&.delete_prefix("https://cocina.sul.stanford.edu/file/")
117
+ end
118
+
119
+ # The DRUID of the object this file belongs to.
120
+ # @note Staging objects can't infer this from the externalIdentifier.
121
+ # @return [String, nil]
122
+ def druid
123
+ @druid || external_id.split("-").first if external_id.present?
124
+ end
125
+
126
+ # Combination of the DRUID and filename to uniquely identify the file.
127
+ # @return [String, nil]
128
+ # @example "ts786ny5936/PC0170_s1_E_0204.jp2"
129
+ def file_id
130
+ "#{druid}/#{filename}" if druid.present? && filename.present?
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,57 @@
1
+ module CocinaDisplay
2
+ module Structural
3
+ # Represents a set of files in a Cocina object.
4
+ class FileSet
5
+ # Underlying hash parsed from Cocina JSON.
6
+ attr_reader :cocina
7
+
8
+ # URL to Stacks environment that will serve this fileset.
9
+ attr_reader :base_url
10
+
11
+ # Initialize the FileSet with Cocina structural data.
12
+ # @param cocina [Hash] Cocina structured data for a single FileSet
13
+ # @param base_url [String] URL to Stacks environment that will serve this fileset
14
+ # @param druid [String, nil] DRUID of the object this fileset belongs to
15
+ def initialize(cocina, base_url: "https://stacks.stanford.edu", druid: nil)
16
+ @cocina = cocina
17
+ @base_url = base_url
18
+ @druid = druid
19
+ end
20
+
21
+ # The declared type of the FileSet, like "image" or "document".
22
+ # @note This can differ from the contained file types.
23
+ # @return [String, nil]
24
+ def type
25
+ cocina["type"]&.delete_prefix("https://cocina.sul.stanford.edu/models/resources/")
26
+ end
27
+
28
+ # All files contained in this FileSet.
29
+ # @return [Array<CocinaDisplay::Structural::File>]
30
+ def files
31
+ @files ||= Array(cocina.dig("structural", "contains")).map do |file|
32
+ CocinaDisplay::Structural::File.new(file, base_url: base_url, druid: druid)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # DRUID of the object this fileset belongs to.
39
+ # @note Inferred from the start of the externalIdentifier.
40
+ # @return [String, nil]
41
+ def druid
42
+ @druid || external_id[/^[a-z]{2}\d{3}[a-z]{2}\d{4}/] if external_id.present?
43
+ end
44
+
45
+ # External identifier for the fileset, minus the URL prefix.
46
+ # @return [String, nil]
47
+ # @note Staging and production formats differ.
48
+ # @example production
49
+ # "bk264hq9320-bk264hq9320_3"
50
+ # @example staging
51
+ # "bh114dk3076_4"
52
+ def external_id
53
+ cocina["externalIdentifier"]&.delete_prefix("https://cocina.sul.stanford.edu/fileSet/")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -109,7 +109,7 @@ module CocinaDisplay
109
109
  # Generate the display title by stripping trailing punctuation from the full title.
110
110
  # @return [String, nil]
111
111
  def display_title_str
112
- full_title_str&.sub(/[\.,;:\/\\]+\z/, "")
112
+ full_title_str&.sub(/[.,;:\/\\]+\z/, "")
113
113
  end
114
114
 
115
115
  # The main title and subtitle, joined together with a colon.
@@ -13,12 +13,12 @@ module CocinaDisplay
13
13
  return compacted_values.first if compacted_values.one?
14
14
 
15
15
  compacted_values.reduce(+"") do |result, value|
16
- result << if value.end_with?(delimiter.strip)
16
+ result << if value.end_with?(delimiter.strip) || value.start_with?(delimiter.strip)
17
17
  value + " "
18
18
  else
19
19
  value + delimiter
20
20
  end
21
- end.delete_suffix(delimiter)
21
+ end.delete_prefix(delimiter).delete_suffix(delimiter).strip
22
22
  end
23
23
 
24
24
  # Recursively flatten structured, and grouped values in Cocina metadata.
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "1.7.0" # :nodoc:
5
+ VERSION = "1.8.0" # :nodoc:
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
@@ -268,6 +268,8 @@ files:
268
268
  - lib/cocina_display/license.rb
269
269
  - lib/cocina_display/note.rb
270
270
  - lib/cocina_display/related_resource.rb
271
+ - lib/cocina_display/structural/file.rb
272
+ - lib/cocina_display/structural/file_set.rb
271
273
  - lib/cocina_display/subjects/subject.rb
272
274
  - lib/cocina_display/subjects/subject_value.rb
273
275
  - lib/cocina_display/title.rb
@@ -296,7 +298,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
296
298
  - !ruby/object:Gem::Version
297
299
  version: '0'
298
300
  requirements: []
299
- rubygems_version: 4.0.4
301
+ rubygems_version: 4.0.3
300
302
  specification_version: 4
301
303
  summary: Helpers for rendering Cocina metadata
302
304
  test_files: []