cocina_display 2.12.0 → 2.13.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: 16e21603fafbb47fe2471e436a256e19eac1d3b69acb85c1bb246d3f39e0d06c
4
- data.tar.gz: '0898b33cdbf5b6c0b9f1f4d9fcdea3b4492d8b265b67fa90a08e14394e476bd2'
3
+ metadata.gz: 8dfb0909c582a5d1e3109bf0c98983e0ebd523af7340722856cb9d4482faa925
4
+ data.tar.gz: c2143a971249f8e6262dced0f4a05ef0d197426d3e86173f808c6f8f9321727e
5
5
  SHA512:
6
- metadata.gz: ddc270e2faa1baa9fbcbbbeaaf8f6fba3ae030e5b6be5741ed407c398a1e1211daaeefdddeeb60ae056748a963c6b1f7c0017b745cc2a2a83d7dcf97de062064
7
- data.tar.gz: d56dc751d95514b82c2cd943a21ab3befee10adeddb69546796a30e28513a9ac4bf3bc6c4e212b5fc0d0d585a9f34ea339e4c6ccfd5224550e6df35eb575c531
6
+ metadata.gz: e83761f763f300b62a7da1624d65f21b89fea93ab4170052c66a324dcd7b306b534fc5b84dc726dbb9945d5b98746398d84d8a9dbc00f3bd467658e12b801241
7
+ data.tar.gz: c06a74d7e19525eb27c71e314f2676d79fb1c3422efa0a9e08f39404feaa001d0c7252986761447a0bee6dd5bad6bc818310dae7a2fa89cb760af836b4bfd33f
@@ -161,9 +161,17 @@ module CocinaDisplay
161
161
  end
162
162
 
163
163
  # Locations associated with this event as a single string.
164
+ # Renders every parallel value, so vernacular and transliterated forms
165
+ # of the same place both appear.
164
166
  # @return [String]
165
167
  def place_str
166
- locations.map(&:to_s).compact_blank.uniq.join(", ")
168
+ location_values.map(&:to_s).compact_blank.uniq.join(", ")
169
+ end
170
+
171
+ # All locations associated with this event, in every language/script.
172
+ # @return [Array<CocinaDisplay::Events::LocationValue>]
173
+ def location_values
174
+ locations.flat_map(&:parallel_values)
167
175
  end
168
176
  end
169
177
  end
@@ -50,7 +50,7 @@ module CocinaDisplay
50
50
  # 3. Keep only unique locations after decoding
51
51
  # @return [Array<String>]
52
52
  def display_locations
53
- unencoded_locs, encoded_locs = locations.partition { |loc| loc.unencoded_value? }
53
+ unencoded_locs, encoded_locs = location_values.partition { |loc| loc.unencoded_value? }
54
54
  locs_for_display = unencoded_locs.presence || encoded_locs
55
55
  locs_for_display.map(&:to_s).compact_blank.uniq
56
56
  end
@@ -1,10 +1,13 @@
1
1
  module CocinaDisplay
2
2
  module Events
3
- # A single location represented in a Cocina event, like a publication place.
4
- class Location
3
+ # A location represented in a Cocina event, like a publication place.
4
+ # Can be expressed as several {LocationValue}s in different languages/scripts.
5
+ class Location < Parallel::Parallel
5
6
  MARC_COUNTRIES_FILE_PATH = CocinaDisplay.root / "config" / "marc_countries.yml"
6
7
 
7
- attr_reader :cocina
8
+ # Common display methods reference the main location value. For parallel
9
+ # values, see #translated_value and #transliterated_value.
10
+ delegate :to_s, :unencoded_value?, :country_name, to: :main_value
8
11
 
9
12
  # A hash mapping MARC country codes to their names.
10
13
  # @return [Hash{String => String}]
@@ -12,50 +15,12 @@ module CocinaDisplay
12
15
  @marc_countries ||= YAML.safe_load_file(MARC_COUNTRIES_FILE_PATH)
13
16
  end
14
17
 
15
- # Initialize a Location object with Cocina structured data.
16
- # @param cocina [Hash] The Cocina structured data for the location.
17
- def initialize(cocina)
18
- @cocina = cocina
19
- end
20
-
21
- # The name of the location.
22
- # Decodes a MARC country code if present and no value was present.
23
- # @return [String, nil]
24
- def to_s
25
- cocina["value"] || country_name
26
- end
27
-
28
- # Is there an unencoded value (name) for this location?
29
- # @return [Boolean]
30
- def unencoded_value?
31
- cocina["value"].present?
32
- end
33
-
34
- # Decoded country name if the location is encoded with a MARC country code.
35
- # @return [String, nil]
36
- def country_name
37
- Location.marc_countries[code] if marc_country? && valid_country_code?
38
- end
39
-
40
18
  private
41
19
 
42
- # A code, like a MARC country code, representing the location.
43
- # @return [String, nil]
44
- def code
45
- cocina["code"]
46
- end
47
-
48
- # Is this a decodable country code?
49
- # Excludes blank values and "xx" (unknown) and "vp" (various places).
50
- # @return [Boolean]
51
- def valid_country_code?
52
- code.present? && ["xx", "vp"].exclude?(code)
53
- end
54
-
55
- # Is this location encoded with a MARC country code?
56
- # @return [Boolean]
57
- def marc_country?
58
- cocina.dig("source", "code") == "marccountry"
20
+ # The class to use for parallel values.
21
+ # @return [Class]
22
+ def parallel_value_class
23
+ LocationValue
59
24
  end
60
25
  end
61
26
  end
@@ -0,0 +1,46 @@
1
+ module CocinaDisplay
2
+ module Events
3
+ # A location in a Cocina event in a single language/script.
4
+ class LocationValue < Parallel::ParallelValue
5
+ # The name of the location.
6
+ # Decodes a MARC country code if present and no value was present.
7
+ # @return [String, nil]
8
+ def to_s
9
+ cocina["value"] || country_name
10
+ end
11
+
12
+ # Is there an unencoded value (name) for this location?
13
+ # @return [Boolean]
14
+ def unencoded_value?
15
+ cocina["value"].present?
16
+ end
17
+
18
+ # Decoded country name if the location is encoded with a MARC country code.
19
+ # @return [String, nil]
20
+ def country_name
21
+ Location.marc_countries[code] if marc_country? && valid_country_code?
22
+ end
23
+
24
+ private
25
+
26
+ # A code, like a MARC country code, representing the location.
27
+ # @return [String, nil]
28
+ def code
29
+ cocina["code"]
30
+ end
31
+
32
+ # Is this a decodable country code?
33
+ # Excludes blank values and "xx" (unknown) and "vp" (various places).
34
+ # @return [Boolean]
35
+ def valid_country_code?
36
+ code.present? && ["xx", "vp"].exclude?(code)
37
+ end
38
+
39
+ # Is this location encoded with a MARC country code?
40
+ # @return [Boolean]
41
+ def marc_country?
42
+ cocina.dig("source", "code") == "marccountry"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -335,6 +335,15 @@ module CocinaDisplay
335
335
  def self.supports?(input_str)
336
336
  input_str.match?(self::PATTERN)
337
337
  end
338
+
339
+ # Move a trailing hemisphere letter to the front, since that is where the
340
+ # decimal normalizer expects it.
341
+ # @example "121.5W" becomes "W121.5"
342
+ # @param [String] value
343
+ # @return [String]
344
+ def self.hemisphere_first(value)
345
+ value.sub(/\A(.+?)([NESW])\z/, '\2\1')
346
+ end
338
347
  end
339
348
 
340
349
  # Mixin that adds normalization for decimal degree coordinates.
@@ -345,17 +354,28 @@ module CocinaDisplay
345
354
 
346
355
  module Helpers
347
356
  # Convert hemispheres to plus/minus signs for parsing.
357
+ # @note The hemisphere can either lead or trail the degrees.
348
358
  # @param [String] coord_str
349
359
  # @return [String]
350
360
  def normalize_coord(coord_str)
351
- coord_str.tr("EN", "+").tr("WS", "-")
361
+ hemisphere_first(coord_str).tr("EN", "+").tr("WS", "-")
352
362
  end
353
363
  end
354
364
  end
355
365
 
356
366
  # Mixin that adds normalization for DMS coordinates.
357
367
  module DMSParser
358
- POINT_PATTERN = /(?<hem>[NESW])(?<deg>\d{1,3})[°⁰º]?(?:(?<min>\d{1,2})[ʹ′']?)?(?:(?<sec>\d{1,2})[ʺ"″]?)?/
368
+ # The degrees, minutes, and seconds of a coordinate, without a hemisphere.
369
+ DMS_PATTERN = /(?<deg>\d{1,3})[°⁰º]?(?:(?<min>\d{1,2})[ʹ′']?)?(?:(?<sec>\d{1,2})[ʺ"″]?)?/
370
+
371
+ # A single coordinate, with the hemisphere either leading or trailing the
372
+ # degrees. A hemisphere is required, so that a bare number is not read as
373
+ # a coordinate. Both spellings are common in MARC 034 and 255$c, and the
374
+ # trailing form is what {Coordinates#format_point} itself emits.
375
+ # @note A trailing hemisphere cannot be followed by digits, otherwise a
376
+ # MARC 034 $b scale like "$b3100000W120°00′00″" would read the scale as
377
+ # the degrees and steal the hemisphere from the coordinate after it.
378
+ POINT_PATTERN = /(?:(?<hem>[NESW])#{DMS_PATTERN}|#{DMS_PATTERN}(?<hem>[NESW])(?!\d))/
359
379
 
360
380
  def self.included(base)
361
381
  base.const_set(:POINT_PATTERN, POINT_PATTERN)
@@ -418,14 +438,6 @@ module CocinaDisplay
418
438
  # found in Cocina structured values, so that Geo::Coord can parse it.
419
439
  # Subclasses define a PATTERN and mix in a parser module for normalize_coord.
420
440
  class CoordinateNormalizer < CoordinatesParser
421
- # Move a trailing hemisphere letter to the front, since that is where the
422
- # parser normalizers expect it.
423
- # @example "121.5W" becomes "W121.5"
424
- # @param [String] value
425
- # @return [String]
426
- def self.hemisphere_first(value)
427
- value.sub(/\A(.+?)([NESW])\z/, '\2\1')
428
- end
429
441
  end
430
442
 
431
443
  # Normalizes DMS values, including the packed form used in MARC 034 subfields.
@@ -436,12 +448,6 @@ module CocinaDisplay
436
448
 
437
449
  # Either DMS punctuation, or a hemisphere paired with packed digits.
438
450
  PATTERN = /[°⁰º′ʹ'″ʺ"]|\A[NESW]\d{4,}\z|\A\d{4,}[NESW]\z/
439
-
440
- # @param [String] value
441
- # @return [String, nil]
442
- def self.normalize_coord(value)
443
- super(hemisphere_first(value))
444
- end
445
451
  end
446
452
 
447
453
  # Normalizes decimal degree values, either signed or paired with a hemisphere.
@@ -452,12 +458,6 @@ module CocinaDisplay
452
458
  include DecimalParser
453
459
 
454
460
  PATTERN = /\A[NESW+-]?\d{1,3}(?:\.\d+)?[NESW]?\z/
455
-
456
- # @param [String] value
457
- # @return [String]
458
- def self.normalize_coord(value)
459
- super(hemisphere_first(value))
460
- end
461
461
  end
462
462
 
463
463
  # Parse for decimal degree points, like "41.891797, 12.486419".
@@ -468,10 +468,12 @@ module CocinaDisplay
468
468
  end
469
469
 
470
470
  # Parser for DMS-format points, like "N34°03′08″ W118°14′37″".
471
+ # @note The hemisphere can either lead or trail the degrees in each half.
472
+ # @example 34°03′08″N 118°14′37″W
471
473
  class DMSPointParser < PointParser
472
474
  include DMSParser
473
475
 
474
- PATTERN = /(?<lat>[^EW]+)(?<lng>[^NS]+)/
476
+ PATTERN = /(?<lat>[NS][^NS]+|[^NS]+[NS])(?<lng>[EW][^EW]+|[^EW]+[EW])/
475
477
  end
476
478
 
477
479
  # DMS-format bounding boxes with varying punctuation, delimited by -- and /.
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "2.12.0" # :nodoc:
5
+ VERSION = "2.13.0" # :nodoc:
6
6
  end
@@ -3,28 +3,29 @@
3
3
  # This script is a simple, brute-force method for finding records that
4
4
  # exhibit certain characteristics in the public Cocina JSON for testing.
5
5
  #
6
- # It queries purl-fetcher for all DRUIDs released to a specific target and
7
- # then fetches each corresponding public Cocina record from PURL and examines it.
8
- #
9
- # You need to be on VPN to do this, as the purl-fetcher API is only accessible
10
- # from within the Stanford network.
6
+ # It reads the public PURL sitemap to enumerate all released DRUIDs and then
7
+ # fetches each corresponding public Cocina record from PURL and examines it.
11
8
  #
12
9
  # To use, modify any of the noted items below, then run:
13
10
  # $ bundle exec ruby script/find_records.rb
14
11
  #
15
12
  # You can exit early with Ctrl-C, and it will report how many records were
16
- # checked before exiting. Running through an entire target will take awhile,
13
+ # checked before exiting. Running through the entire sitemap will take awhile,
17
14
  # on the order of 30 minutes or more.
18
15
 
19
16
  require "benchmark"
17
+ require "net/http"
20
18
  require "pp"
21
- require "purl_fetcher/client"
19
+ require "rexml/document"
20
+ require "stringio"
21
+ require "uri"
22
+ require "zlib"
22
23
  require "cocina_display"
23
24
  require "cocina_display/utils"
24
25
 
25
- # This should correspond to one of the release targets available in purl-fetcher,
26
- # i.e. "Searchworks", "Earthworks", etc.
27
- RELEASE_TARGET = "Searchworks"
26
+ # The PURL sitemap index. This points to one or more gzipped child sitemaps,
27
+ # each of which lists PURL URLs (one per released DRUID).
28
+ SITEMAP_URL = "https://purl.stanford.edu/system/sitemap/sitemap.xml.gz"
28
29
 
29
30
  # Modify this expression to match the JSON path you want to search, or just
30
31
  # modify the `examine_record` method directly.
@@ -36,8 +37,33 @@ def examine_record(record)
36
37
  record.path(PATH_EXPR).map { |value, _node, _key, path| [path, CocinaDisplay::Utils.deep_compact_blank(value)] }
37
38
  end
38
39
 
39
- # Track total records in target and how many we've seen
40
- released_to_target = []
40
+ # Fetch a URL and return the response body, transparently decompressing it if
41
+ # it was served (or named) as gzip.
42
+ def fetch_gzipped(url)
43
+ body = Net::HTTP.get(URI(url))
44
+ Zlib::GzipReader.new(StringIO.new(body)).read
45
+ rescue Zlib::GzipFile::Error
46
+ body
47
+ end
48
+
49
+ # Extract every <loc> value from a sitemap or sitemap index document.
50
+ def sitemap_locs(xml)
51
+ REXML::Document.new(xml).get_elements("//loc").map { |loc| loc.text.strip }
52
+ end
53
+
54
+ # Walk the sitemap index and yield the DRUID for every URL in each child sitemap.
55
+ def each_druid_in_sitemap(sitemap_url)
56
+ return enum_for(:each_druid_in_sitemap, sitemap_url) unless block_given?
57
+
58
+ sitemap_locs(fetch_gzipped(sitemap_url)).each do |child_sitemap_url|
59
+ sitemap_locs(fetch_gzipped(child_sitemap_url)).each do |purl_url|
60
+ yield File.basename(URI(purl_url).path)
61
+ end
62
+ end
63
+ end
64
+
65
+ # Track total records and how many we've seen
66
+ druids = []
41
67
  processed_records = 0
42
68
 
43
69
  # Handle Ctrl-C gracefully
@@ -46,24 +72,21 @@ Signal.trap("INT") do
46
72
  exit
47
73
  end
48
74
 
49
- # Fetch everything from purl-fetcher; note that this is one single HTTP request
50
- # that returns a massive JSON response it can be quite slow
51
- puts "Finding records released to #{RELEASE_TARGET}..."
52
- client = PurlFetcher::Client::Reader.new
75
+ # Read the sitemap; this involves a handful of HTTP requests (the index plus
76
+ # each child sitemap) that are relatively quick compared to purl-fetcher.
77
+ puts "Finding released records from the PURL sitemap..."
53
78
  query_time = Benchmark.realtime do
54
- client.released_to(RELEASE_TARGET).each do |record|
55
- released_to_target << record["druid"].delete_prefix("druid:")
56
- end
57
- rescue Faraday::ConnectionFailed => e
58
- puts "Connection failed: #{e.message}; are you on VPN?"
79
+ each_druid_in_sitemap(SITEMAP_URL) { |druid| druids << druid }
80
+ rescue => e
81
+ puts "Failed to read sitemap: #{e.message}"
59
82
  exit 1
60
83
  end
61
- puts "Found #{released_to_target.size} records released to #{RELEASE_TARGET} in #{query_time.round(2)} seconds"
84
+ puts "Found #{druids.size} records in the sitemap in #{query_time.round(2)} seconds"
62
85
 
63
86
  # Iterate through the list of DRUIDs and fetch each one from PURL, creating a
64
87
  # CocinaRecord object. Then call our examine_record method on it and if
65
88
  # anything was returned, print the DRUID and the results.
66
- released_to_target.each do |druid|
89
+ druids.each do |druid|
67
90
  begin
68
91
  cocina_record = CocinaDisplay::CocinaRecord.fetch(druid)
69
92
  processed_records += 1
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.0
4
+ version: 2.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: janeway-jsonpath
@@ -217,20 +217,6 @@ dependencies:
217
217
  - - ">="
218
218
  - !ruby/object:Gem::Version
219
219
  version: 1.9.1
220
- - !ruby/object:Gem::Dependency
221
- name: purl_fetcher-client
222
- requirement: !ruby/object:Gem::Requirement
223
- requirements:
224
- - - "~>"
225
- - !ruby/object:Gem::Version
226
- version: '3.1'
227
- type: :development
228
- prerelease: false
229
- version_requirements: !ruby/object:Gem::Requirement
230
- requirements:
231
- - - "~>"
232
- - !ruby/object:Gem::Version
233
- version: '3.1'
234
220
  email:
235
221
  - budak@stanford.edu
236
222
  executables: []
@@ -279,6 +265,7 @@ files:
279
265
  - lib/cocina_display/events/event.rb
280
266
  - lib/cocina_display/events/imprint.rb
281
267
  - lib/cocina_display/events/location.rb
268
+ - lib/cocina_display/events/location_value.rb
282
269
  - lib/cocina_display/events/note.rb
283
270
  - lib/cocina_display/forms/form.rb
284
271
  - lib/cocina_display/forms/genre.rb
@@ -326,7 +313,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
326
313
  - !ruby/object:Gem::Version
327
314
  version: '0'
328
315
  requirements: []
329
- rubygems_version: 3.6.2
316
+ rubygems_version: 4.0.15
330
317
  specification_version: 4
331
318
  summary: Helpers for rendering Cocina metadata
332
319
  test_files: []