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.
@@ -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
@@ -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.flat_map { |node| flatten_nested_values(node, output) } if cocina.is_a?(Array)
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(hash, output = {})
58
- hash.each do |key, value|
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "0.6.0" # :nodoc:
5
+ VERSION = "1.0.0" # :nodoc:
6
6
  end