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 +4 -4
- data/lib/cocina_display/concerns/geospatial.rb +6 -1
- data/lib/cocina_display/events/event.rb +9 -1
- data/lib/cocina_display/events/imprint.rb +1 -1
- data/lib/cocina_display/events/location.rb +10 -45
- data/lib/cocina_display/events/location_value.rb +46 -0
- data/lib/cocina_display/geospatial.rb +193 -53
- data/lib/cocina_display/version.rb +1 -1
- data/script/find_records.rb +46 -23
- metadata +3 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dfb0909c582a5d1e3109bf0c98983e0ebd523af7340722856cb9d4482faa925
|
|
4
|
+
data.tar.gz: c2143a971249f8e6262dced0f4a05ef0d197426d3e86173f808c6f8f9321727e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [[
|
|
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
|
-
|
|
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 =
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
43
|
-
# @return [
|
|
44
|
-
def
|
|
45
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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 :
|
|
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
|
-
|
|
161
|
-
|
|
199
|
+
southwest = Geo::Coord.parse("#{south}, #{west}")
|
|
200
|
+
northeast = Geo::Coord.parse("#{north}, #{east}")
|
|
162
201
|
|
|
163
202
|
# Must be parsable
|
|
164
|
-
return unless
|
|
203
|
+
return unless southwest && northeast
|
|
165
204
|
|
|
166
|
-
#
|
|
167
|
-
return if
|
|
205
|
+
# A box can wrap east-west, but never north-south
|
|
206
|
+
return if southwest.lat > northeast.lat
|
|
168
207
|
|
|
169
|
-
new(
|
|
208
|
+
new(southwest: southwest, northeast: northeast)
|
|
170
209
|
end
|
|
171
210
|
|
|
172
|
-
# Construct a BoundingBox from two Geo::Coord points.
|
|
173
|
-
# @param [Geo::Coord]
|
|
174
|
-
# @param [Geo::Coord]
|
|
175
|
-
def initialize(
|
|
176
|
-
@
|
|
177
|
-
@
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
"#{
|
|
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((
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
297
|
+
# @example [[34.0522, -118.2437], [34.1996, -117.9522]]
|
|
230
298
|
def as_bbox
|
|
231
|
-
[[
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
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>[^
|
|
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 = /(?<
|
|
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 = /(?<
|
|
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(?<
|
|
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(?<
|
|
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
|
data/script/find_records.rb
CHANGED
|
@@ -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
|
|
7
|
-
#
|
|
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
|
|
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 "
|
|
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
|
|
26
|
-
#
|
|
27
|
-
|
|
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
|
-
#
|
|
40
|
-
|
|
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
|
-
#
|
|
50
|
-
#
|
|
51
|
-
puts "Finding records
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 #{
|
|
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
|
-
|
|
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.
|
|
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.
|
|
316
|
+
rubygems_version: 4.0.15
|
|
330
317
|
specification_version: 4
|
|
331
318
|
summary: Helpers for rendering Cocina metadata
|
|
332
319
|
test_files: []
|