cocina_display 2.11.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: 3cdd3c641ebe565578770ae4f79867ccc886bdaefa17b2bfa966cc17c262c58b
4
- data.tar.gz: 2d0e16f879c06393a3e74c2e323896384040721c95f73bc2e65fb428c4c9d94a
3
+ metadata.gz: 8dfb0909c582a5d1e3109bf0c98983e0ebd523af7340722856cb9d4482faa925
4
+ data.tar.gz: c2143a971249f8e6262dced0f4a05ef0d197426d3e86173f808c6f8f9321727e
5
5
  SHA512:
6
- metadata.gz: 98248c3a9f4a68ef0e8fac5261f979702e66c529f086d9287d111d580af06bc517536b5636fb0e5d1ca02588264af17c91e9185630c5f35f287c60af5cc6de77
7
- data.tar.gz: b6c79b6672639e13f31f446ca239d11434be0ba152656f93bd328ce576271d3e6db6ace9adec9b136d40cd325fce562d0d30317cabf5b4afe37d505962a5e93f
6
+ metadata.gz: e83761f763f300b62a7da1624d65f21b89fea93ab4170052c66a324dcd7b306b534fc5b84dc726dbb9945d5b98746398d84d8a9dbc00f3bd467658e12b801241
7
+ data.tar.gz: c06a74d7e19525eb27c71e314f2676d79fb1c3422efa0a9e08f39404feaa001d0c7252986761447a0bee6dd5bad6bc818310dae7a2fa89cb760af836b4bfd33f
@@ -14,6 +14,8 @@ module CocinaDisplay
14
14
  # All valid coordinate data formatted for indexing into a Solr RPT field.
15
15
  # @note This type of field accommodates both points and bounding boxes.
16
16
  # @note In WKT, points have longitude first, unlike {coordinates_as_point}.
17
+ # @note A box crossing the antimeridian is split at the date line, so it is
18
+ # rendered as a MULTIPOLYGON of its two halves.
17
19
  # @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#rpt
18
20
  # @return [Array<String>]
19
21
  # @example ["POINT(-118.2437 34.0522)", "POLYGON((-118.2437 34.0522, -118.2437 34.1996, -117.9522 34.1996, -117.9522 34.0522, -118.2437 34.0522))"]
@@ -23,6 +25,7 @@ module CocinaDisplay
23
25
 
24
26
  # All valid coordinate data formatted for indexing into a Solr BBoxField.
25
27
  # @note Points are not included since they can't be represented as a box.
28
+ # @note West is greater than east for a box crossing the antimeridian.
26
29
  # @see https://solr.apache.org/guide/solr/latest/query-guide/spatial-search.html#bboxfield
27
30
  # @return [Array<String>]
28
31
  # @example ["ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"]
@@ -40,8 +43,10 @@ module CocinaDisplay
40
43
  end
41
44
 
42
45
  # All valid coordinate data formatted as bounding boxes.
43
- # Format is [[min_lat, min_long], [max_lat, max_long]].
46
+ # Format is [[south, west], [north, east]].
44
47
  # @note Points are not included since they can't be represented as a box.
48
+ # @note For a box crossing the antimeridian, east is carried past 180 so that
49
+ # the pair still reads southwest to northeast.
45
50
  # @return [Array<Array<Array<Float>>>]
46
51
  def coordinates_as_bbox
47
52
  coordinate_objects.map(&:as_bbox).compact.uniq
@@ -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
@@ -20,15 +20,17 @@ module CocinaDisplay
20
20
  # @return [Coordinates, nil]
21
21
  def from_structured_values(structured_values)
22
22
  if structured_values.size == 2
23
- lat = structured_values.find { |v| v["type"] == "latitude" }&.dig("value")
24
- lng = structured_values.find { |v| v["type"] == "longitude" }&.dig("value")
25
- Point.from_coords(lat: lat, lng: lng)
23
+ Point.from_coords(
24
+ lat: structured_value(structured_values, "latitude"),
25
+ lng: structured_value(structured_values, "longitude")
26
+ )
26
27
  elsif structured_values.size == 4
27
- north = structured_values.find { |v| v["type"] == "north" }&.dig("value")
28
- south = structured_values.find { |v| v["type"] == "south" }&.dig("value")
29
- east = structured_values.find { |v| v["type"] == "east" }&.dig("value")
30
- west = structured_values.find { |v| v["type"] == "west" }&.dig("value")
31
- BoundingBox.from_coords(west: west, east: east, north: north, south: south)
28
+ BoundingBox.from_coords(
29
+ west: structured_value(structured_values, "west"),
30
+ east: structured_value(structured_values, "east"),
31
+ north: structured_value(structured_values, "north"),
32
+ south: structured_value(structured_values, "south")
33
+ )
32
34
  end
33
35
  end
34
36
 
@@ -54,6 +56,36 @@ module CocinaDisplay
54
56
  # Use the matching parser to parse the string
55
57
  parser_class.parse(match_str)
56
58
  end
59
+
60
+ private
61
+
62
+ # Find a single coordinate value of the given type in structured data.
63
+ # @param [Array<Hash>] structured_values
64
+ # @param [String] type like "west" or "latitude"
65
+ # @return [String, nil]
66
+ def structured_value(structured_values, type)
67
+ value = structured_values.find { |v| v["type"] == type }&.dig("value")
68
+ normalize_value(value) if value.present?
69
+ end
70
+
71
+ # Standardize a single coordinate value so that Geo::Coord can parse it.
72
+ # Chooses a normalizer based on the string format, since structured values
73
+ # can be decimal degrees or DMS, including the packed MARC 034 form.
74
+ # @param [String] value
75
+ # @return [String, nil] nil if the format isn't recognized
76
+ # @example "W1210000" becomes "121°0′0″W"
77
+ def normalize_value(value)
78
+ # Remove all whitespace for easier matching/parsing
79
+ match_str = value.gsub(/\s+/, "")
80
+
81
+ # Try each normalizer in order until one matches; bail out if none do
82
+ normalizer_class = [
83
+ DMSCoordinateNormalizer,
84
+ DecimalCoordinateNormalizer
85
+ ].find { |normalizer| normalizer.supports?(match_str) }
86
+
87
+ normalizer_class&.normalize_coord(match_str)
88
+ end
57
89
  end
58
90
 
59
91
  protected
@@ -146,35 +178,72 @@ module CocinaDisplay
146
178
  end
147
179
  end
148
180
 
149
- # A bounding box defined by two corner points.
181
+ # A bounding box defined by its southwest and northeast corner points.
182
+ # The box can wrap east-west across the antimeridian, in which case its west
183
+ # edge is numerically east of its east edge. Both Solr's rectangle syntax and
184
+ # GeoJSON spell a crossing box that way, as do MARC 034 $d/$e, which are the
185
+ # westernmost and easternmost longitudes rather than the minimum and maximum.
186
+ # @see https://datatracker.ietf.org/doc/html/rfc7946#section-5.2
150
187
  class BoundingBox < Coordinates
151
- attr_reader :min_point, :max_point
188
+ attr_reader :southwest, :northeast
152
189
 
153
190
  # Construct a BoundingBox from west, east, north, and south string values.
191
+ # West and east are used as given, so a box that crosses the antimeridian
192
+ # is preserved instead of rejected.
154
193
  # @param [String] west western longitude
155
194
  # @param [String] east eastern longitude
156
195
  # @param [String] north northern latitude
157
196
  # @param [String] south southern latitude
158
197
  # @return [BoundingBox, nil] nil if parsing fails
159
198
  def self.from_coords(west:, east:, north:, south:)
160
- min_point = Geo::Coord.parse("#{south}, #{west}")
161
- max_point = Geo::Coord.parse("#{north}, #{east}")
199
+ southwest = Geo::Coord.parse("#{south}, #{west}")
200
+ northeast = Geo::Coord.parse("#{north}, #{east}")
162
201
 
163
202
  # Must be parsable
164
- return unless min_point && max_point
203
+ return unless southwest && northeast
165
204
 
166
- # Ensure min_point is southwest and max_point is northeast
167
- return if min_point.lat > max_point.lat || min_point.lng > max_point.lng
205
+ # A box can wrap east-west, but never north-south
206
+ return if southwest.lat > northeast.lat
168
207
 
169
- new(min_point: min_point, max_point: max_point)
208
+ new(southwest: southwest, northeast: northeast)
170
209
  end
171
210
 
172
- # Construct a BoundingBox from two Geo::Coord points.
173
- # @param [Geo::Coord] min_point
174
- # @param [Geo::Coord] max_point
175
- def initialize(min_point:, max_point:)
176
- @min_point = min_point
177
- @max_point = max_point
211
+ # Construct a BoundingBox from two corner Geo::Coord points.
212
+ # @param [Geo::Coord] southwest
213
+ # @param [Geo::Coord] northeast
214
+ def initialize(southwest:, northeast:)
215
+ @southwest = southwest
216
+ @northeast = northeast
217
+ end
218
+
219
+ # The westernmost longitude of the box.
220
+ # @return [BigDecimal]
221
+ def west
222
+ southwest.lng
223
+ end
224
+
225
+ # The easternmost longitude of the box.
226
+ # @return [BigDecimal]
227
+ def east
228
+ northeast.lng
229
+ end
230
+
231
+ # The northernmost latitude of the box.
232
+ # @return [BigDecimal]
233
+ def north
234
+ northeast.lat
235
+ end
236
+
237
+ # The southernmost latitude of the box.
238
+ # @return [BigDecimal]
239
+ def south
240
+ southwest.lat
241
+ end
242
+
243
+ # True if the box wraps east-west across the antimeridian.
244
+ # @return [Boolean]
245
+ def crosses_antimeridian?
246
+ west > east
178
247
  end
179
248
 
180
249
  # Format for display in DMS format, adapted from ISO 6709 standard.
@@ -183,33 +252,31 @@ module CocinaDisplay
183
252
  # @return [String]
184
253
  # @example "118°14′37″W -- 117°56′55″W / 34°03′08″N -- 34°11′59″N"
185
254
  def to_s
186
- min_lat, min_lng = format_point(min_point)
187
- max_lat, max_lng = format_point(max_point)
188
- "#{min_lng} -- #{max_lng} / #{max_lat} -- #{min_lat}"
255
+ south_str, west_str = format_point(southwest)
256
+ north_str, east_str = format_point(northeast)
257
+ "#{west_str} -- #{east_str} / #{north_str} -- #{south_str}"
189
258
  end
190
259
 
191
260
  # Format using the Well-Known Text (WKT) representation.
192
261
  # @note Limits decimals to 6 places.
262
+ # @note A box crossing the antimeridian is split into two polygons at the
263
+ # date line, so that every longitude stays within bounds.
193
264
  # @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
265
+ # @see https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.9
194
266
  # @return [String]
195
267
  def as_wkt
196
- "POLYGON((%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f))" % [
197
- min_point.lng, min_point.lat,
198
- max_point.lng, min_point.lat,
199
- max_point.lng, max_point.lat,
200
- min_point.lng, max_point.lat,
201
- min_point.lng, min_point.lat
202
- ]
268
+ return "POLYGON(#{ring(west, east)})" unless crosses_antimeridian?
269
+
270
+ "MULTIPOLYGON((#{ring(west, 180)}), (#{ring(-180, east)}))"
203
271
  end
204
272
 
205
273
  # Format using the CQL ENVELOPE representation.
206
274
  # @note Limits decimals to 6 places.
275
+ # @note West is greater than east for a box crossing the antimeridian.
207
276
  # @example "ENVELOPE(-118.2437, -117.9522, 34.1996, 34.0522)"
208
277
  # @return [String]
209
278
  def as_envelope
210
- "ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [
211
- min_point.lng, max_point.lng, max_point.lat, min_point.lat
212
- ]
279
+ "ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [west, east, north, south]
213
280
  end
214
281
 
215
282
  # The box center point as a space-separated x y (longitude latitude) pair.
@@ -217,18 +284,43 @@ module CocinaDisplay
217
284
  # @example "-118.2437 34.0522"
218
285
  # @return [String]
219
286
  def as_point
220
- azimuth = min_point.azimuth(max_point)
221
- distance = min_point.distance(max_point)
222
- center = min_point.endpoint(distance / 2, azimuth)
223
- "%.6f %.6f" % [center.lng, center.lat]
287
+ center_lng = (west + unwrapped_east) / 2
288
+ center_lng -= 360 if center_lng > 180
289
+ "%.6f %.6f" % [center_lng, (south + north) / 2]
224
290
  end
225
291
 
226
292
  # Format the bounding box as an array of two coordinate pairs [[S, W], [N, E]].
227
293
  # @note Limits decimals to 6 places.
294
+ # @note For a box crossing the antimeridian, east is carried past 180 so that
295
+ # the pair still reads southwest to northeast.
228
296
  # @return [Array<Array<Float>>]
229
- # @example [[-118.2437, 34.0522], [-117.9522, 34.1996]]
297
+ # @example [[34.0522, -118.2437], [34.1996, -117.9522]]
230
298
  def as_bbox
231
- [[min_point.lat, min_point.lng], [max_point.lat, max_point.lng]]
299
+ [[south, west], [north, unwrapped_east]]
300
+ end
301
+
302
+ private
303
+
304
+ # The east edge as a continuous longitude, carried past 180 if the box
305
+ # crosses the antimeridian.
306
+ # @return [BigDecimal]
307
+ def unwrapped_east
308
+ crosses_antimeridian? ? east + 360 : east
309
+ end
310
+
311
+ # A closed WKT linear ring for the box, spanning the given longitudes.
312
+ # @note Limits decimals to 6 places.
313
+ # @param [Numeric] west_lng
314
+ # @param [Numeric] east_lng
315
+ # @return [String]
316
+ def ring(west_lng, east_lng)
317
+ "(%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f)" % [
318
+ west_lng, south,
319
+ east_lng, south,
320
+ east_lng, north,
321
+ west_lng, north,
322
+ west_lng, south
323
+ ]
232
324
  end
233
325
  end
234
326
 
@@ -243,6 +335,15 @@ module CocinaDisplay
243
335
  def self.supports?(input_str)
244
336
  input_str.match?(self::PATTERN)
245
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
246
347
  end
247
348
 
248
349
  # Mixin that adds normalization for decimal degree coordinates.
@@ -253,17 +354,28 @@ module CocinaDisplay
253
354
 
254
355
  module Helpers
255
356
  # Convert hemispheres to plus/minus signs for parsing.
357
+ # @note The hemisphere can either lead or trail the degrees.
256
358
  # @param [String] coord_str
257
359
  # @return [String]
258
360
  def normalize_coord(coord_str)
259
- coord_str.tr("EN", "+").tr("WS", "-")
361
+ hemisphere_first(coord_str).tr("EN", "+").tr("WS", "-")
260
362
  end
261
363
  end
262
364
  end
263
365
 
264
366
  # Mixin that adds normalization for DMS coordinates.
265
367
  module DMSParser
266
- 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))/
267
379
 
268
380
  def self.included(base)
269
381
  base.const_set(:POINT_PATTERN, POINT_PATTERN)
@@ -313,15 +425,41 @@ module CocinaDisplay
313
425
  matches = input_str.match(self::PATTERN)
314
426
  return unless matches
315
427
 
316
- min_lng = normalize_coord(matches[:min_lng])
317
- max_lng = normalize_coord(matches[:max_lng])
318
- min_lat = normalize_coord(matches[:min_lat])
319
- max_lat = normalize_coord(matches[:max_lat])
428
+ west = normalize_coord(matches[:west])
429
+ east = normalize_coord(matches[:east])
430
+ south = normalize_coord(matches[:south])
431
+ north = normalize_coord(matches[:north])
320
432
 
321
- BoundingBox.from_coords(west: min_lng, east: max_lng, north: max_lat, south: min_lat)
433
+ BoundingBox.from_coords(west: west, east: east, north: north, south: south)
322
434
  end
323
435
  end
324
436
 
437
+ # Base class for normalizers that standardize a single coordinate value, as
438
+ # found in Cocina structured values, so that Geo::Coord can parse it.
439
+ # Subclasses define a PATTERN and mix in a parser module for normalize_coord.
440
+ class CoordinateNormalizer < CoordinatesParser
441
+ end
442
+
443
+ # Normalizes DMS values, including the packed form used in MARC 034 subfields.
444
+ # @example W1210000
445
+ # @example 121°14′48″W
446
+ class DMSCoordinateNormalizer < CoordinateNormalizer
447
+ include DMSParser
448
+
449
+ # Either DMS punctuation, or a hemisphere paired with packed digits.
450
+ PATTERN = /[°⁰º′ʹ'″ʺ"]|\A[NESW]\d{4,}\z|\A\d{4,}[NESW]\z/
451
+ end
452
+
453
+ # Normalizes decimal degree values, either signed or paired with a hemisphere.
454
+ # @note Degrees are limited to 3 digits so that packed DMS isn't read as decimal.
455
+ # @example -121.24658
456
+ # @example W126.04
457
+ class DecimalCoordinateNormalizer < CoordinateNormalizer
458
+ include DecimalParser
459
+
460
+ PATTERN = /\A[NESW+-]?\d{1,3}(?:\.\d+)?[NESW]?\z/
461
+ end
462
+
325
463
  # Parse for decimal degree points, like "41.891797, 12.486419".
326
464
  class DecimalPointParser < PointParser
327
465
  include DecimalParser
@@ -330,10 +468,12 @@ module CocinaDisplay
330
468
  end
331
469
 
332
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
333
473
  class DMSPointParser < PointParser
334
474
  include DMSParser
335
475
 
336
- PATTERN = /(?<lat>[^EW]+)(?<lng>[^NS]+)/
476
+ PATTERN = /(?<lat>[NS][^NS]+|[^NS]+[NS])(?<lng>[EW][^EW]+|[^EW]+[EW])/
337
477
  end
338
478
 
339
479
  # DMS-format bounding boxes with varying punctuation, delimited by -- and /.
@@ -342,7 +482,7 @@ module CocinaDisplay
342
482
  class DMSBoundingBoxParser < BoundingBoxParser
343
483
  include DMSParser
344
484
 
345
- PATTERN = /(?<min_lng>.+?)-+(?<max_lng>.+)\/(?<max_lat>.+?)-+(?<min_lat>.+)/
485
+ PATTERN = /(?<west>.+?)-+(?<east>.+)\/(?<north>.+?)-+(?<south>.+)/
346
486
  end
347
487
 
348
488
  # Format that pairs hemispheres with decimal degrees.
@@ -350,21 +490,21 @@ module CocinaDisplay
350
490
  class DecimalBoundingBoxParser < BoundingBoxParser
351
491
  include DecimalParser
352
492
 
353
- PATTERN = /(?<min_lng>[0-9.EW]+?)-+(?<max_lng>[0-9.EW]+)\/(?<max_lat>[0-9.NS]+?)-+(?<min_lat>[0-9.NS]+)/
493
+ PATTERN = /(?<west>[0-9.EW]+?)-+(?<east>[0-9.EW]+)\/(?<north>[0-9.NS]+?)-+(?<south>[0-9.NS]+)/
354
494
  end
355
495
 
356
496
  # DMS-format data that appears to come from MARC 034 subfields.
357
497
  # @see https://www.oclc.org/bibformats/en/0xx/034.html
358
498
  # @example $dW0963700$eW0900700$fN0433000$gN040220
359
499
  class MarcDMSBoundingBoxParser < DMSBoundingBoxParser
360
- PATTERN = /\$d(?<min_lng>[WENS].+)\$e(?<max_lng>[WENS].+)\$f(?<max_lat>[WENS].+)\$g(?<min_lat>[WENS].+)/
500
+ PATTERN = /\$d(?<west>[WENS].+)\$e(?<east>[WENS].+)\$f(?<north>[WENS].+)\$g(?<south>[WENS].+)/
361
501
  end
362
502
 
363
503
  # Decimal degree format data that appears to come from MARC 034 subfields.
364
504
  # @see https://www.oclc.org/bibformats/en/0xx/034.html
365
505
  # @example $d-112.0785250$e-111.6012719$f037.6516503$g036.8583209
366
506
  class MarcDecimalBoundingBoxParser < DecimalBoundingBoxParser
367
- PATTERN = /\$d(?<min_lng>[0-9.-]+)\$e(?<max_lng>[0-9.-]+)\$f(?<max_lat>[0-9.-]+)\$g(?<min_lat>[0-9.-]+)/
507
+ PATTERN = /\$d(?<west>[0-9.-]+)\$e(?<east>[0-9.-]+)\$f(?<north>[0-9.-]+)\$g(?<south>[0-9.-]+)/
368
508
  end
369
509
  end
370
510
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "2.11.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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.0
4
+ version: 2.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
@@ -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: 4.0.6
316
+ rubygems_version: 4.0.15
330
317
  specification_version: 4
331
318
  summary: Helpers for rendering Cocina metadata
332
319
  test_files: []