cocina_display 0.6.0 → 1.0.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/cocina_record.rb +4 -0
- data/lib/cocina_display/concerns/contributors.rb +8 -4
- data/lib/cocina_display/concerns/events.rb +7 -7
- data/lib/cocina_display/concerns/geospatial.rb +65 -0
- data/lib/cocina_display/concerns/languages.rb +20 -0
- data/lib/cocina_display/concerns/subjects.rb +43 -16
- data/lib/cocina_display/contributors/contributor.rb +100 -0
- data/lib/cocina_display/contributors/name.rb +100 -0
- data/lib/cocina_display/contributors/role.rb +51 -0
- data/lib/cocina_display/dates/date_range.rb +21 -9
- data/lib/cocina_display/events/event.rb +2 -2
- data/lib/cocina_display/events/imprint.rb +2 -3
- data/lib/cocina_display/events/location.rb +3 -3
- data/lib/cocina_display/geospatial.rb +348 -0
- data/lib/cocina_display/language.rb +47 -0
- data/lib/cocina_display/subjects/subject.rb +49 -0
- data/lib/cocina_display/subjects/subject_value.rb +159 -0
- data/lib/cocina_display/utils.rb +12 -5
- data/lib/cocina_display/version.rb +1 -1
- data/lib/cocina_display/vocabularies/marc_country_codes.rb +393 -0
- data/lib/cocina_display/vocabularies/marc_relator_codes.rb +318 -0
- data/lib/cocina_display/vocabularies/searchworks_languages.rb +526 -0
- data/script/deep_compact.rb +13 -0
- metadata +43 -6
- data/lib/cocina_display/contributor.rb +0 -234
- data/lib/cocina_display/marc_country_codes.rb +0 -394
- data/lib/cocina_display/marc_relator_codes.rb +0 -314
- data/lib/cocina_display/subject.rb +0 -127
@@ -0,0 +1,348 @@
|
|
1
|
+
require "geo/coord"
|
2
|
+
|
3
|
+
module CocinaDisplay
|
4
|
+
module Geospatial
|
5
|
+
# Abstract class representing multiple geospatial coordinates, like a point or box.
|
6
|
+
class Coordinates
|
7
|
+
class << self
|
8
|
+
# Convert Cocina structured data into a Coordinates object.
|
9
|
+
# Chooses a parsing strategy based on the cocina structure.
|
10
|
+
# @param [Hash] cocina
|
11
|
+
# @return [Coordinates, nil]
|
12
|
+
def from_cocina(cocina)
|
13
|
+
return from_structured_values(cocina["structuredValue"]) if Array(cocina["structuredValue"]).any?
|
14
|
+
parse(cocina["value"]) if cocina["value"].present?
|
15
|
+
end
|
16
|
+
|
17
|
+
# Convert structured values into the appropriate Coordinates object.
|
18
|
+
# Handles points and bounding boxes.
|
19
|
+
# @param [Array<Hash>] structured_values
|
20
|
+
# @return [Coordinates, nil]
|
21
|
+
def from_structured_values(structured_values)
|
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)
|
26
|
+
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)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Convert a single string value into a Coordinates object.
|
36
|
+
# Chooses a parsing strategy based on the string format.
|
37
|
+
# @param [String] value
|
38
|
+
# @return [Coordinates, nil]
|
39
|
+
def parse(value)
|
40
|
+
# Remove all whitespace for easier matching/parsing
|
41
|
+
match_str = value.gsub(/[\s]+/, "")
|
42
|
+
|
43
|
+
# Try each parser in order until one matches; bail out if none do
|
44
|
+
parser_class = [
|
45
|
+
MarcDecimalBoundingBoxParser,
|
46
|
+
MarcDMSBoundingBoxParser,
|
47
|
+
DecimalBoundingBoxParser,
|
48
|
+
DMSBoundingBoxParser,
|
49
|
+
DecimalPointParser,
|
50
|
+
DMSPointParser
|
51
|
+
].find { |parser| parser.supports?(match_str) }
|
52
|
+
return unless parser_class
|
53
|
+
|
54
|
+
# Use the matching parser to parse the string
|
55
|
+
parser_class.parse(match_str)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
protected
|
60
|
+
|
61
|
+
# Format a point for display in DMS, adapted from ISO 6709 standard.
|
62
|
+
# @note This format adapts the "Annex D" human representation style.
|
63
|
+
# @see https://en.wikipedia.org/wiki/ISO_6709
|
64
|
+
# @param [Geo::Coord] point
|
65
|
+
# @return [Array<String>] [latitude, longitude]
|
66
|
+
# @example ["34°03′08″N", "118°14′37″W"]
|
67
|
+
def format_point(point)
|
68
|
+
# Geo::Coord#strfcoord performs rounding & carrying for us, but
|
69
|
+
# it can't natively zero-pad minutes and seconds to two digits
|
70
|
+
[
|
71
|
+
normalize_coord(point.strfcoord("%latd %latm %lats %lath")),
|
72
|
+
normalize_coord(point.strfcoord("%lngd %lngm %lngs %lngh"))
|
73
|
+
]
|
74
|
+
end
|
75
|
+
|
76
|
+
# Reformat a coordinate string to ensure two-digit minutes and seconds.
|
77
|
+
# Expects space-separated output of Geo::Coord#strfcoord.
|
78
|
+
# @example "121 4 6 W" becomes "121°04′06″W"
|
79
|
+
# @param [String] coord_str
|
80
|
+
# @return [String]
|
81
|
+
def normalize_coord(coord_str)
|
82
|
+
d, m, s, h = coord_str.split(" ")
|
83
|
+
"%d°%02d′%02d″%s" % [d.to_i, m.to_i, s.to_i, h]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# A single geospatial point with latitude and longitude.
|
88
|
+
class Point < Coordinates
|
89
|
+
attr_reader :point
|
90
|
+
|
91
|
+
# Construct a Point from latitude and longitude string values.
|
92
|
+
# @param [String] lat latitude
|
93
|
+
# @param [String] lng longitude
|
94
|
+
# @return [Point, nil] nil if parsing fails
|
95
|
+
def self.from_coords(lat:, lng:)
|
96
|
+
point = Geo::Coord.parse("#{lat}, #{lng}")
|
97
|
+
return unless point
|
98
|
+
|
99
|
+
new(point)
|
100
|
+
end
|
101
|
+
|
102
|
+
# Construct a Point from a single Geo::Coord point.
|
103
|
+
# @param [Geo::Coord] point
|
104
|
+
def initialize(point)
|
105
|
+
@point = point
|
106
|
+
end
|
107
|
+
|
108
|
+
# Format for display in DMS format, adapted from ISO 6709 standard.
|
109
|
+
# @note This format adapts the "Annex D" human representation style.
|
110
|
+
# @see https://en.wikipedia.org/wiki/ISO_6709
|
111
|
+
# @return [String]
|
112
|
+
# @example "34°03′08″N 118°14′37″W"
|
113
|
+
def to_s
|
114
|
+
format_point(point).join(" ")
|
115
|
+
end
|
116
|
+
|
117
|
+
# Format using the Well-Known Text (WKT) representation.
|
118
|
+
# @note Limits decimals to 6 places.
|
119
|
+
# @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
|
120
|
+
# @example "POINT(34.0522 -118.2437)"
|
121
|
+
# @return [String]
|
122
|
+
def as_wkt
|
123
|
+
"POINT(%.6f %.6f)" % [point.lat, point.lng]
|
124
|
+
end
|
125
|
+
|
126
|
+
# Format using the CQL ENVELOPE representation.
|
127
|
+
# @note This is impossible for a single point; we always return nil.
|
128
|
+
# @return [nil]
|
129
|
+
def as_envelope
|
130
|
+
nil
|
131
|
+
end
|
132
|
+
|
133
|
+
# Format as a comma-separated latitude,longitude pair.
|
134
|
+
# @note Limits decimals to 6 places.
|
135
|
+
# @example "34.0522,-118.2437"
|
136
|
+
# @return [String]
|
137
|
+
def as_point
|
138
|
+
"%.6f,%.6f" % [point.lat, point.lng]
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# A bounding box defined by two corner points.
|
143
|
+
class BoundingBox < Coordinates
|
144
|
+
attr_reader :min_point, :max_point
|
145
|
+
|
146
|
+
# Construct a BoundingBox from west, east, north, and south string values.
|
147
|
+
# @param [String] west western longitude
|
148
|
+
# @param [String] east eastern longitude
|
149
|
+
# @param [String] north northern latitude
|
150
|
+
# @param [String] south southern latitude
|
151
|
+
# @return [BoundingBox, nil] nil if parsing fails
|
152
|
+
def self.from_coords(west:, east:, north:, south:)
|
153
|
+
min_point = Geo::Coord.parse("#{north}, #{west}")
|
154
|
+
max_point = Geo::Coord.parse("#{south}, #{east}")
|
155
|
+
|
156
|
+
# Must be parsable
|
157
|
+
return unless min_point && max_point
|
158
|
+
|
159
|
+
new(min_point: min_point, max_point: max_point)
|
160
|
+
end
|
161
|
+
|
162
|
+
# Construct a BoundingBox from two Geo::Coord points.
|
163
|
+
# @param [Geo::Coord] min_point
|
164
|
+
# @param [Geo::Coord] max_point
|
165
|
+
def initialize(min_point:, max_point:)
|
166
|
+
@min_point = min_point
|
167
|
+
@max_point = max_point
|
168
|
+
end
|
169
|
+
|
170
|
+
# Format for display in DMS format, adapted from ISO 6709 standard.
|
171
|
+
# @note This format adapts the "Annex D" human representation style.
|
172
|
+
# @see https://en.wikipedia.org/wiki/ISO_6709
|
173
|
+
# @return [String]
|
174
|
+
# @example "118°14′37″W -- 117°56′55″W / 34°03′08″N -- 34°11′59″N"
|
175
|
+
def to_s
|
176
|
+
min_lat, min_lng = format_point(min_point)
|
177
|
+
max_lat, max_lng = format_point(max_point)
|
178
|
+
"#{min_lng} -- #{max_lng} / #{min_lat} -- #{max_lat}"
|
179
|
+
end
|
180
|
+
|
181
|
+
# Format using the Well-Known Text (WKT) representation.
|
182
|
+
# @note Limits decimals to 6 places.
|
183
|
+
# @see https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry
|
184
|
+
# @return [String]
|
185
|
+
def as_wkt
|
186
|
+
"POLYGON((%.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f, %.6f %.6f))" % [
|
187
|
+
min_point.lng, min_point.lat,
|
188
|
+
max_point.lng, min_point.lat,
|
189
|
+
max_point.lng, max_point.lat,
|
190
|
+
min_point.lng, max_point.lat,
|
191
|
+
min_point.lng, min_point.lat
|
192
|
+
]
|
193
|
+
end
|
194
|
+
|
195
|
+
# Format using the CQL ENVELOPE representation.
|
196
|
+
# @note Limits decimals to 6 places.
|
197
|
+
# @example "ENVELOPE(-118.243700, -117.952200, 34.199600, 34.052200)"
|
198
|
+
# @return [String]
|
199
|
+
def as_envelope
|
200
|
+
"ENVELOPE(%.6f, %.6f, %.6f, %.6f)" % [
|
201
|
+
min_point.lng, max_point.lng, max_point.lat, min_point.lat
|
202
|
+
]
|
203
|
+
end
|
204
|
+
|
205
|
+
# The box center point as a comma-separated latitude,longitude pair.
|
206
|
+
# @note Limits decimals to 6 places.
|
207
|
+
# @example "34.0522,-118.2437"
|
208
|
+
# @return [String]
|
209
|
+
def as_point
|
210
|
+
azimuth = min_point.azimuth(max_point)
|
211
|
+
distance = min_point.distance(max_point)
|
212
|
+
center = min_point.endpoint(distance / 2, azimuth)
|
213
|
+
"%.6f,%.6f" % [center.lat, center.lng]
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# Base class for parsers that convert strings into Coordinates objects.
|
218
|
+
# Subclasses must define at least the PATTERN constant and self.parse method.
|
219
|
+
class CoordinatesParser
|
220
|
+
PATTERN = nil
|
221
|
+
|
222
|
+
# If true, use this parser for the given input string.
|
223
|
+
# @param [String] input_str
|
224
|
+
# @return [Boolean]
|
225
|
+
def self.supports?(input_str)
|
226
|
+
input_str.match?(self::PATTERN)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# Mixin that adds normalization for decimal degree coordinates.
|
231
|
+
module DecimalParser
|
232
|
+
def self.included(base)
|
233
|
+
base.extend(Helpers)
|
234
|
+
end
|
235
|
+
|
236
|
+
module Helpers
|
237
|
+
# Convert hemispheres to plus/minus signs for parsing.
|
238
|
+
# @param [String] coord_str
|
239
|
+
# @return [String]
|
240
|
+
def normalize_coord(coord_str)
|
241
|
+
coord_str.tr("EN", "+").tr("WS", "-")
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Mixin that adds normalization for DMS coordinates.
|
247
|
+
module DMSParser
|
248
|
+
POINT_PATTERN = /(?<hem>[NESW])(?<deg>\d{1,3})[°⁰º]?(?:(?<min>\d{1,2})[ʹ′']?)?(?:(?<sec>\d{1,2})[ʺ"″]?)?/
|
249
|
+
|
250
|
+
def self.included(base)
|
251
|
+
base.const_set(:POINT_PATTERN, POINT_PATTERN)
|
252
|
+
base.extend(Helpers)
|
253
|
+
end
|
254
|
+
|
255
|
+
module Helpers
|
256
|
+
# Standardize coordinate format so Geo::Coord can parse it.
|
257
|
+
# @param [String] coord_str
|
258
|
+
# @return [String]
|
259
|
+
def normalize_coord(coord_str)
|
260
|
+
matches = coord_str.match(self::POINT_PATTERN)
|
261
|
+
return unless matches
|
262
|
+
|
263
|
+
hem = matches[:hem]
|
264
|
+
deg = matches[:deg].to_i
|
265
|
+
min = matches[:min].to_i
|
266
|
+
sec = matches[:sec].to_i
|
267
|
+
|
268
|
+
"#{deg}°#{min}′#{sec}″#{hem}"
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Base class for point parsers.
|
274
|
+
class PointParser < CoordinatesParser
|
275
|
+
# Parse the input string into a Point, or nil if parsing fails.
|
276
|
+
# @param [String] input_str
|
277
|
+
# @return [Point, nil]
|
278
|
+
def self.parse(input_str)
|
279
|
+
matches = input_str.match(self::PATTERN)
|
280
|
+
return unless matches
|
281
|
+
|
282
|
+
lat = normalize_coord(matches[:lat])
|
283
|
+
lng = normalize_coord(matches[:lng])
|
284
|
+
|
285
|
+
Point.from_coords(lat: lat, lng: lng)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
# Base class for bounding box parsers.
|
290
|
+
class BoundingBoxParser < CoordinatesParser
|
291
|
+
# Parse the input string into a BoundingBox, or nil if parsing fails.
|
292
|
+
# @param [String] input_str
|
293
|
+
# @return [BoundingBox, nil]
|
294
|
+
def self.parse(input_str)
|
295
|
+
matches = input_str.match(self::PATTERN)
|
296
|
+
return unless matches
|
297
|
+
|
298
|
+
min_lng = normalize_coord(matches[:min_lng])
|
299
|
+
max_lng = normalize_coord(matches[:max_lng])
|
300
|
+
min_lat = normalize_coord(matches[:min_lat])
|
301
|
+
max_lat = normalize_coord(matches[:max_lat])
|
302
|
+
|
303
|
+
BoundingBox.from_coords(west: min_lng, east: max_lng, north: min_lat, south: max_lat)
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Parse for decimal degree points, like "41.891797, 12.486419".
|
308
|
+
class DecimalPointParser < PointParser
|
309
|
+
include DecimalParser
|
310
|
+
PATTERN = /(?<lat>[0-9.EW\+\-]+),(?<lng>[0-9.NS\+\-]+)/
|
311
|
+
end
|
312
|
+
|
313
|
+
# Parser for DMS-format points, like "N34°03′08″ W118°14′37″".
|
314
|
+
class DMSPointParser < PointParser
|
315
|
+
include DMSParser
|
316
|
+
PATTERN = /(?<lat>[^EW]+)(?<lng>[^NS]+)/
|
317
|
+
end
|
318
|
+
|
319
|
+
# DMS-format bounding boxes with varying punctuation, delimited by -- and /.
|
320
|
+
# @note This data can come from the MARC 255$c field.
|
321
|
+
# @see https://www.oclc.org/bibformats/en/2xx/255.html#subfieldc
|
322
|
+
class DMSBoundingBoxParser < BoundingBoxParser
|
323
|
+
include DMSParser
|
324
|
+
PATTERN = /(?<min_lng>.+?)-+(?<max_lng>.+)\/(?<min_lat>.+?)-+(?<max_lat>.+)/
|
325
|
+
end
|
326
|
+
|
327
|
+
# Format that pairs hemispheres with decimal degrees.
|
328
|
+
# @example W 126.04--W 052.03/N 050.37--N 006.8
|
329
|
+
class DecimalBoundingBoxParser < BoundingBoxParser
|
330
|
+
include DecimalParser
|
331
|
+
PATTERN = /(?<min_lng>[0-9.EW]+?)-+(?<max_lng>[0-9.EW]+)\/(?<min_lat>[0-9.NS]+?)-+(?<max_lat>[0-9.NS]+)/
|
332
|
+
end
|
333
|
+
|
334
|
+
# DMS-format data that appears to come from MARC 034 subfields.
|
335
|
+
# @see https://www.oclc.org/bibformats/en/0xx/034.html
|
336
|
+
# @example $dW0963700$eW0900700$fN0433000$gN040220
|
337
|
+
class MarcDMSBoundingBoxParser < DMSBoundingBoxParser
|
338
|
+
PATTERN = /\$d(?<min_lng>[WENS].+)\$e(?<max_lng>[WENS].+)\$f(?<min_lat>[WENS].+)\$g(?<max_lat>[WENS].+)/
|
339
|
+
end
|
340
|
+
|
341
|
+
# Decimal degree format data that appears to come from MARC 034 subfields.
|
342
|
+
# @see https://www.oclc.org/bibformats/en/0xx/034.html
|
343
|
+
# @example $d-112.0785250$e-111.6012719$f037.6516503$g036.8583209
|
344
|
+
class MarcDecimalBoundingBoxParser < DecimalBoundingBoxParser
|
345
|
+
PATTERN = /\$d(?<min_lng>[0-9.-]+)\$e(?<max_lng>[0-9.-]+)\$f(?<min_lat>[0-9.-]+)\$g(?<max_lat>[0-9.-]+)/
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "iso639"
|
2
|
+
require_relative "vocabularies/searchworks_languages"
|
3
|
+
|
4
|
+
module CocinaDisplay
|
5
|
+
# A language associated with part or all of a Cocina object.
|
6
|
+
class Language
|
7
|
+
attr_reader :cocina
|
8
|
+
|
9
|
+
# Create a Language object from Cocina structured data.
|
10
|
+
# @param cocina [Hash]
|
11
|
+
def initialize(cocina)
|
12
|
+
@cocina = cocina
|
13
|
+
end
|
14
|
+
|
15
|
+
# The language name for display.
|
16
|
+
# @return [String, nil]
|
17
|
+
def to_s
|
18
|
+
cocina["value"] || decoded_value
|
19
|
+
end
|
20
|
+
|
21
|
+
# The language code, e.g. an ISO 639 code like "eng" or "spa".
|
22
|
+
# @return [String, nil]
|
23
|
+
def code
|
24
|
+
cocina["code"]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Decoded name of the language based on the code, if present.
|
28
|
+
# @return [String, nil]
|
29
|
+
def decoded_value
|
30
|
+
Vocabularies::SEARCHWORKS_LANGUAGES[code] || (Iso639[code] if iso_639?)
|
31
|
+
end
|
32
|
+
|
33
|
+
# True if the language is recognized by Searchworks.
|
34
|
+
# @see CocinaDisplay::Vocabularies::SEARCHWORKS_LANGUAGES
|
35
|
+
# @return [Boolean]
|
36
|
+
def searchworks_language?
|
37
|
+
Vocabularies::SEARCHWORKS_LANGUAGES.value?(to_s)
|
38
|
+
end
|
39
|
+
|
40
|
+
# True if the language has a code sourced from the ISO 639 vocabulary.
|
41
|
+
# @see https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
|
42
|
+
# @return [Boolean]
|
43
|
+
def iso_639?
|
44
|
+
cocina.dig("source", "code")&.start_with? "iso639"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require_relative "../utils"
|
2
|
+
require_relative "subject_value"
|
3
|
+
|
4
|
+
module CocinaDisplay
|
5
|
+
module Subjects
|
6
|
+
# Base class for subjects in Cocina structured data.
|
7
|
+
class Subject
|
8
|
+
attr_reader :cocina
|
9
|
+
|
10
|
+
# Initialize a Subject object with Cocina structured data.
|
11
|
+
# @param cocina [Hash] The Cocina structured data for the subject.
|
12
|
+
def initialize(cocina)
|
13
|
+
@cocina = cocina
|
14
|
+
end
|
15
|
+
|
16
|
+
# The top-level type of the subject.
|
17
|
+
# @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#subject-types
|
18
|
+
# @return [String, nil]
|
19
|
+
def type
|
20
|
+
cocina["type"]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Array of display strings for each value in the subject.
|
24
|
+
# Used for search, where each value should be indexed separately.
|
25
|
+
# @return [Array<String>]
|
26
|
+
def display_values
|
27
|
+
subject_values.map(&:to_s).compact_blank
|
28
|
+
end
|
29
|
+
|
30
|
+
# A string representation of the entire subject, concatenated for display.
|
31
|
+
# @return [String]
|
32
|
+
def to_s
|
33
|
+
Utils.compact_and_join(display_values, delimiter: " > ")
|
34
|
+
end
|
35
|
+
|
36
|
+
# Individual values composing this subject.
|
37
|
+
# Can be multiple if the Cocina featured nested data.
|
38
|
+
# If no type was specified on a value, uses the top-level subject type.
|
39
|
+
# @return [Array<SubjectValue>]
|
40
|
+
def subject_values
|
41
|
+
@subject_values ||= Utils.flatten_nested_values(cocina, atomic_types: SubjectValue.atomic_types).map do |value|
|
42
|
+
subject_value = SubjectValue.from_cocina(value)
|
43
|
+
subject_value.type ||= type
|
44
|
+
subject_value
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
require "geo/coord"
|
2
|
+
|
3
|
+
require_relative "subject"
|
4
|
+
require_relative "../contributors/name"
|
5
|
+
require_relative "../title_builder"
|
6
|
+
require_relative "../dates/date"
|
7
|
+
require_relative "../geospatial"
|
8
|
+
|
9
|
+
module CocinaDisplay
|
10
|
+
module Subjects
|
11
|
+
# A descriptive value that can be part of a Subject.
|
12
|
+
class SubjectValue
|
13
|
+
attr_reader :cocina
|
14
|
+
|
15
|
+
# The type of the subject value, like "person", "title", or "time".
|
16
|
+
# @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#subject-part-types-for-structured-value
|
17
|
+
attr_accessor :type
|
18
|
+
|
19
|
+
# Create a SubjectValue from Cocina structured data.
|
20
|
+
# @param cocina [Hash] The Cocina structured data for the subject.
|
21
|
+
# @return [SubjectValue]
|
22
|
+
def self.from_cocina(cocina)
|
23
|
+
SUBJECT_VALUE_TYPES.fetch(cocina["type"], SubjectValue).new(cocina)
|
24
|
+
end
|
25
|
+
|
26
|
+
# All subject value types that should not be further destructured.
|
27
|
+
# @return [Array<String>]
|
28
|
+
def self.atomic_types
|
29
|
+
SUBJECT_VALUE_TYPES.keys - ["place"]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Initialize a SubjectValue object with Cocina structured data.
|
33
|
+
# @param cocina [Hash] The Cocina structured data for the subject value.
|
34
|
+
def initialize(cocina)
|
35
|
+
@cocina = cocina
|
36
|
+
@type = cocina["type"]
|
37
|
+
end
|
38
|
+
|
39
|
+
# The display string for the subject value.
|
40
|
+
# Subclasses should override this method to provide specific formatting.
|
41
|
+
# @return [String]
|
42
|
+
def to_s
|
43
|
+
cocina["value"]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# A subject value representing a named entity.
|
48
|
+
class NameSubjectValue < SubjectValue
|
49
|
+
attr_reader :name
|
50
|
+
|
51
|
+
# Initialize a NameSubjectValue object with Cocina structured data.
|
52
|
+
# @param cocina [Hash] The Cocina structured data for the subject.
|
53
|
+
def initialize(cocina)
|
54
|
+
super
|
55
|
+
@name = Contributors::Name.new(cocina)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Use the contributor name formatting rules for display.
|
59
|
+
# @return [String] The formatted name string, including life dates
|
60
|
+
# @see CocinaDisplay::Contributor::Name#to_s
|
61
|
+
def to_s
|
62
|
+
name.to_s(with_date: true)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# A subject value representing an entity with a title.
|
67
|
+
class TitleSubjectValue < SubjectValue
|
68
|
+
# Construct a title string to use for display.
|
69
|
+
# @see CocinaDisplay::TitleBuilder.build
|
70
|
+
# @note Unclear how often structured title subjects occur "in the wild".
|
71
|
+
# @return [String]
|
72
|
+
def to_s
|
73
|
+
TitleBuilder.build([cocina])
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# A subject value representing a date and/or time.
|
78
|
+
class TemporalSubjectValue < SubjectValue
|
79
|
+
attr_reader :date
|
80
|
+
|
81
|
+
def initialize(cocina)
|
82
|
+
super
|
83
|
+
@date = Dates::Date.from_cocina(cocina)
|
84
|
+
end
|
85
|
+
|
86
|
+
# @return [String] The formatted date/time string for display
|
87
|
+
def to_s
|
88
|
+
date.qualified_value
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# A subject value representing a named place.
|
93
|
+
class PlaceSubjectValue < SubjectValue
|
94
|
+
# A URI identifying the place, if available.
|
95
|
+
# @return [String, nil]
|
96
|
+
def uri
|
97
|
+
cocina["uri"]
|
98
|
+
end
|
99
|
+
|
100
|
+
# True if the place has a geonames.org URI.
|
101
|
+
# @return [Boolean]
|
102
|
+
def geonames?
|
103
|
+
uri&.include?("sws.geonames.org")
|
104
|
+
end
|
105
|
+
|
106
|
+
# Unique identifier for the place in geonames.org.
|
107
|
+
# @return [String, nil]
|
108
|
+
def geonames_id
|
109
|
+
uri&.split("/")&.last if geonames?
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# A subject value containing geographic coordinates, like a point or box.
|
114
|
+
class CoordinatesSubjectValue < SubjectValue
|
115
|
+
attr_reader :coordinates
|
116
|
+
|
117
|
+
def initialize(cocina)
|
118
|
+
super
|
119
|
+
@coordinates = Geospatial::Coordinates.from_cocina(cocina)
|
120
|
+
end
|
121
|
+
|
122
|
+
# The normalized DMS string for the coordinates.
|
123
|
+
# Falls back to the raw value if parsing fails.
|
124
|
+
# @return [String, nil]
|
125
|
+
def to_s
|
126
|
+
coordinates&.to_s || super
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Map Cocina subject types to specific SubjectValue classes for rendering.
|
133
|
+
# @see SubjectValue#type
|
134
|
+
SUBJECT_VALUE_TYPES = {
|
135
|
+
"person" => CocinaDisplay::Subjects::NameSubjectValue,
|
136
|
+
"family" => CocinaDisplay::Subjects::NameSubjectValue,
|
137
|
+
"organization" => CocinaDisplay::Subjects::NameSubjectValue,
|
138
|
+
"conference" => CocinaDisplay::Subjects::NameSubjectValue,
|
139
|
+
"event" => CocinaDisplay::Subjects::NameSubjectValue,
|
140
|
+
"name" => CocinaDisplay::Subjects::NameSubjectValue,
|
141
|
+
"title" => CocinaDisplay::Subjects::TitleSubjectValue,
|
142
|
+
"time" => CocinaDisplay::Subjects::TemporalSubjectValue,
|
143
|
+
"area" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
144
|
+
"city" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
145
|
+
"city section" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
146
|
+
"continent" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
147
|
+
"country" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
148
|
+
"county" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
149
|
+
"coverage" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
150
|
+
"extraterrestrial area" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
151
|
+
"island" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
152
|
+
"place" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
153
|
+
"region" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
154
|
+
"state" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
155
|
+
"territory" => CocinaDisplay::Subjects::PlaceSubjectValue,
|
156
|
+
"point coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue,
|
157
|
+
"map coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue,
|
158
|
+
"bounding box coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue
|
159
|
+
}.freeze
|
data/lib/cocina_display/utils.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
require "active_support/core_ext/object/blank"
|
2
|
+
|
1
3
|
module CocinaDisplay
|
2
4
|
# Helper methods for string formatting, etc.
|
3
5
|
module Utils
|
@@ -24,6 +26,7 @@ module CocinaDisplay
|
|
24
26
|
# @return [Array<Hash>] List of node hashes with "value" present
|
25
27
|
# @param cocina [Hash] The Cocina structured data to flatten
|
26
28
|
# @param output [Array] Used for recursion, should be empty on first call
|
29
|
+
# @param atomic_types [Array<String>] Types considered atomic; will not be flattened
|
27
30
|
# @example simple value
|
28
31
|
# cocina = { "value" => "John Doe", "type" => "name" }
|
29
32
|
# Utils.flatten_nested_values(cocina)
|
@@ -36,14 +39,15 @@ module CocinaDisplay
|
|
36
39
|
# cocina = { "parallelValue" => [{"value" => "foo" }, { "structuredValue" => [{"value" => "bar"}, {"value" => "baz"}] }] }
|
37
40
|
# Utils.flatten_nested_values(cocina)
|
38
41
|
# #=> [{"value" => "foo"}, {"value" => "foo"}, {"value" => "baz"}]
|
39
|
-
def self.flatten_nested_values(cocina, output = [])
|
42
|
+
def self.flatten_nested_values(cocina, output = [], atomic_types: [])
|
40
43
|
return [cocina] if cocina["value"].present?
|
41
|
-
return cocina
|
44
|
+
return [cocina] if atomic_types.include?(cocina["type"])
|
45
|
+
return cocina.flat_map { |node| flatten_nested_values(node, output, atomic_types: atomic_types) } if cocina.is_a?(Array)
|
42
46
|
|
43
47
|
nested_values = Array(cocina["structuredValue"]) + Array(cocina["parallelValue"]) + Array(cocina["groupedValue"])
|
44
48
|
return output unless nested_values.any?
|
45
49
|
|
46
|
-
nested_values.flat_map { |node| flatten_nested_values(node, output) }
|
50
|
+
nested_values.flat_map { |node| flatten_nested_values(node, output, atomic_types: atomic_types) }
|
47
51
|
end
|
48
52
|
|
49
53
|
# Recursively remove empty values from a hash, including nested hashes and arrays.
|
@@ -54,8 +58,10 @@ module CocinaDisplay
|
|
54
58
|
# hash = { "name" => "", "age" => nil, "address => { "city" => "Anytown", "state" => [] } }
|
55
59
|
# # Utils.remove_empty_values(hash)
|
56
60
|
# #=> { "address" => { "city" => "Anytown" } }
|
57
|
-
def self.deep_compact_blank(
|
58
|
-
|
61
|
+
def self.deep_compact_blank(node, output = {})
|
62
|
+
return node unless node.is_a?(Hash)
|
63
|
+
|
64
|
+
node.each do |key, value|
|
59
65
|
if value.is_a?(Hash)
|
60
66
|
nested = deep_compact_blank(value)
|
61
67
|
output[key] = nested unless nested.empty?
|
@@ -66,6 +72,7 @@ module CocinaDisplay
|
|
66
72
|
output[key] = value
|
67
73
|
end
|
68
74
|
end
|
75
|
+
|
69
76
|
output
|
70
77
|
end
|
71
78
|
end
|