cocina_display 0.4.0 → 0.6.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,134 @@
1
+ require "active_support/core_ext/enumerable"
2
+
3
+ module CocinaDisplay
4
+ module Concerns
5
+ # Methods for extracting format/genre information from a Cocina object
6
+ module Forms
7
+ # Resource types of the object, expressed in SearchWorks controlled vocabulary.
8
+ # @return [Array<String>]
9
+ def resource_types
10
+ mapped_values = resource_type_values.flat_map { |resource_type| searchworks_resource_type(resource_type) }
11
+ mapped_values << "Dataset" if dataset?
12
+ mapped_values.uniq
13
+ end
14
+
15
+ # Physical or digital forms of the object.
16
+ # @return [Array<String>]
17
+ # @example GIS dataset (nz187ct8959)
18
+ # record.forms #=> ["map", "optical disc", "electronic resource"]
19
+ def forms
20
+ path("$.description.form..[?@.type == 'form'].value").uniq
21
+ end
22
+
23
+ # Extent of the object, such as "1 audiotape" or "1 map".
24
+ # @return [Array<String>]
25
+ # @example Oral history interview (sw705fr7011)
26
+ # record.extents #=> ["1 audiotape", "1 transcript"]
27
+ def extents
28
+ path("$.description.form..[?@.type == 'extent'].value").uniq
29
+ end
30
+
31
+ # Genres of the object, capitalized for display.
32
+ # @return [Array<String>]
33
+ # @example GIS dataset (nz187ct8959)
34
+ # record.genres #=> ["Cartographic dataset", "Geospatial data", "Geographic information systems data"]
35
+ def genres
36
+ path("$.description.form..[?@.type == 'genre'].value").map(&:upcase_first).uniq
37
+ end
38
+
39
+ # Genres of the object, with additional values added for search/faceting.
40
+ # @note These values are added for discovery in SearchWorks but not for display.
41
+ # @return [Array<String>]
42
+ def genres_search
43
+ genres.tap do |values|
44
+ values << "Thesis/Dissertation" if values.include?("Thesis")
45
+ values << "Conference proceedings" if values.include?("Conference publication")
46
+ values << "Government document" if values.include?("Government publication")
47
+ end.uniq
48
+ end
49
+
50
+ # Is the object a periodical or serial?
51
+ # @return [Boolean]
52
+ def periodical?
53
+ issuance_terms.include?("periodical") || issuance_terms.include?("serial") || frequency.any?
54
+ end
55
+
56
+ # Is the object a cartographic resource?
57
+ # @return [Boolean]
58
+ def cartographic?
59
+ resource_type_values.include?("cartographic")
60
+ end
61
+
62
+ # Is the object a web archive?
63
+ # @return [Boolean]
64
+ def archived_website?
65
+ genres.include?("Archived website")
66
+ end
67
+
68
+ # Is the object a dataset?
69
+ # @return [Boolean]
70
+ def dataset?
71
+ genres.include?("Dataset")
72
+ end
73
+
74
+ private
75
+
76
+ # Map a resource type to SearchWorks format value(s).
77
+ # @param resource_type [String] The resource type to map.
78
+ # @return [Array<String>]
79
+ def searchworks_resource_type(resource_type)
80
+ values = []
81
+
82
+ case resource_type
83
+ when "cartographic"
84
+ values << "Map"
85
+ when "manuscript", "mixed material"
86
+ values << "Archive/Manuscript"
87
+ when "moving image"
88
+ values << "Video"
89
+ when "notated music"
90
+ values << "Music score"
91
+ when "software, multimedia"
92
+ # Prevent GIS datasets from being labeled as "Software"
93
+ values << "Software/Multimedia" unless cartographic? || dataset?
94
+ when "sound recording-musical"
95
+ values << "Music recording"
96
+ when "sound recording-nonmusical", "sound recording"
97
+ values << "Sound recording"
98
+ when "still image"
99
+ values << "Image"
100
+ when "text"
101
+ # Can potentially map to periodical AND website if both are true. Only
102
+ # 2 records currently (2025) in Searchworks do this, but it is real.
103
+ if periodical? || archived_website?
104
+ values << "Journal/Periodical" if periodical?
105
+ values << "Archived website" if archived_website?
106
+ else
107
+ values << "Book"
108
+ end
109
+ when "three dimensional object"
110
+ values << "Object"
111
+ end
112
+
113
+ values.compact_blank
114
+ end
115
+
116
+ # Issuance terms for a work, drawn from the event notes.
117
+ # @return [Array<String>]
118
+ def issuance_terms
119
+ path("$.description.event.*.note[?@.type == 'issuance'].value").map(&:downcase).uniq
120
+ end
121
+
122
+ # Frequency terms for a periodical, drawn from the event notes.
123
+ # @return [Array<String>]
124
+ def frequency
125
+ path("$.description.event.*.note[?@.type == 'frequency'].value").map(&:downcase).uniq
126
+ end
127
+
128
+ # Values of the resource type form field prior to mapping.
129
+ def resource_type_values
130
+ path("$.description.form..[?@.type == 'resource type'].value").uniq
131
+ end
132
+ end
133
+ end
134
+ end
@@ -40,11 +40,19 @@ module CocinaDisplay
40
40
 
41
41
  # The HRID of the item in FOLIO, if defined.
42
42
  # @note This doesn't imply the object is available in Searchworks at this ID.
43
+ # @param [refresh] [Boolean] Filter to links with refresh set to this value.
43
44
  # @return [String, nil]
44
- # @example
45
+ # @example With a link regardless of refresh:
45
46
  # record.folio_hrid #=> "a12845814"
46
- def folio_hrid
47
- path("$.identification.catalogLinks[?(@.catalog == 'folio')].catalogRecordId").first
47
+ # @example With a link that is not refreshed:
48
+ # record.folio_hrid(refresh: true) #=> nil
49
+ def folio_hrid(refresh: nil)
50
+ link = path("$.identification.catalogLinks[?(@.catalog == 'folio')]").first
51
+ hrid = link&.dig("catalogRecordId")
52
+ return if hrid.blank?
53
+ return hrid if refresh.nil?
54
+
55
+ (link["refresh"] == refresh) ? hrid : nil
48
56
  end
49
57
 
50
58
  # The FOLIO HRID if defined, otherwise the bare DRUID.
@@ -0,0 +1,91 @@
1
+ require_relative "../subject"
2
+
3
+ module CocinaDisplay
4
+ module Concerns
5
+ # Methods for extracting and formatting subject information.
6
+ module Subjects
7
+ # All unique subjects that are topics, formatted as strings for display.
8
+ # @return [Array<String>]
9
+ def subject_topics
10
+ subjects.filter { |s| s.type == "topic" }.map(&:display_str).uniq
11
+ end
12
+
13
+ # All unique subjects that are genres, formatted as strings for display.
14
+ # @return [Array<String>]
15
+ def subject_genres
16
+ subjects.filter { |s| s.type == "genre" }.map(&:display_str).uniq
17
+ end
18
+
19
+ # All unique subjects that are titles, formatted as strings for display.
20
+ # @return [Array<String>]
21
+ def subject_titles
22
+ subjects.filter { |s| s.type == "title" }.map(&:display_str).uniq
23
+ end
24
+
25
+ # All unique subjects that are date/time info, formatted as strings for display.
26
+ # @return [Array<String>]
27
+ def subject_temporal
28
+ subjects.filter { |s| s.type == "time" }.map(&:display_str).uniq
29
+ end
30
+
31
+ # All unique subjects that are occupations, formatted as strings for display.
32
+ # @return [Array<String>]
33
+ def subject_occupations
34
+ subjects.filter { |s| s.type == "occupation" }.map(&:display_str).uniq
35
+ end
36
+
37
+ # All unique subjects that are names of entities, formatted as strings for display.
38
+ # @note Multiple types are handled: person, family, organization, conference, etc.
39
+ # @see CocinaDisplay::NameSubject
40
+ # @return [Array<String>]
41
+ def subject_names
42
+ subjects.filter { |s| s.is_a? NameSubject }.map(&:display_str).uniq
43
+ end
44
+
45
+ # Combination of all subject values for searching.
46
+ # @see #subject_topics_other
47
+ # @see #subject_temporal_genre
48
+ # @return [Array<String>]
49
+ def subject_all
50
+ subject_topics_other + subject_temporal_genre
51
+ end
52
+
53
+ # Combination of topic, occupation, name, and title subject values for searching.
54
+ # @see #subject_topics
55
+ # @see #subject_other
56
+ # @return [Array<String>]
57
+ def subject_topics_other
58
+ subject_topics + subject_other
59
+ end
60
+
61
+ # Combination of occupation, name, and title subject values for searching.
62
+ # @see #subject_occupations
63
+ # @see #subject_names
64
+ # @see #subject_titles
65
+ # @return [Array<String>]
66
+ def subject_other
67
+ subject_occupations + subject_names + subject_titles
68
+ end
69
+
70
+ # Combination of temporal and genre subject values for searching.
71
+ # @see #subject_temporal
72
+ # @see #subject_genres
73
+ # @return [Array<String>]
74
+ def subject_temporal_genre
75
+ subject_temporal + subject_genres
76
+ end
77
+
78
+ private
79
+
80
+ # All subjects, accessible as Subject objects.
81
+ # Checks both description.subject and description.geographic.subject.
82
+ # @return [Array<Subject>]
83
+ def subjects
84
+ @subjects ||= Enumerator::Chain.new(
85
+ path("$.description.subject[*]"),
86
+ path("$.description.geographic.*.subject[*]")
87
+ ).map { |s| Subject.from_cocina(s) }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/blank"
5
5
  require "active_support/core_ext/array/conversions"
6
6
 
7
7
  require_relative "utils"
8
+ require_relative "marc_relator_codes"
8
9
 
9
10
  module CocinaDisplay
10
11
  # A contributor to a work, such as an author or publisher.
@@ -51,7 +52,13 @@ module CocinaDisplay
51
52
  # Does this contributor have a role that indicates they are an author?
52
53
  # @return [Boolean]
53
54
  def author?
54
- roles.any? { |role| role["value"] =~ /(author|creator)/i }
55
+ roles.any?(&:author?)
56
+ end
57
+
58
+ # Does this contributor have a role that indicates they are a publisher?
59
+ # @return [Boolean]
60
+ def publisher?
61
+ roles.any?(&:publisher?)
55
62
  end
56
63
 
57
64
  # Does this contributor have any roles defined?
@@ -72,11 +79,9 @@ module CocinaDisplay
72
79
  # If there are multiple roles, they are joined with commas.
73
80
  # @return [String]
74
81
  def display_role
75
- roles.map { |role| role["value"] }.to_sentence
82
+ roles.map(&:display_str).to_sentence
76
83
  end
77
84
 
78
- private
79
-
80
85
  # All names in the Cocina as Name objects.
81
86
  # @return [Array<Name>]
82
87
  def names
@@ -86,7 +91,7 @@ module CocinaDisplay
86
91
  # All roles in the Cocina structured data.
87
92
  # @return [Array<Hash>]
88
93
  def roles
89
- Array(cocina["role"])
94
+ @roles ||= Array(cocina["role"]).map { |role| Role.new(role) }
90
95
  end
91
96
 
92
97
  # A name associated with a contributor.
@@ -100,6 +105,10 @@ module CocinaDisplay
100
105
  end
101
106
 
102
107
  # The display string for the name, optionally including life dates.
108
+ # Uses these values in order, if present:
109
+ # 1. Unstructured value
110
+ # 2. Any structured/parallel values marked as "display"
111
+ # 3. Joined structured values, optionally with life dates
103
112
  # @param with_date [Boolean] Include life dates, if present
104
113
  # @return [String]
105
114
  # @example no dates
@@ -107,7 +116,11 @@ module CocinaDisplay
107
116
  # @example with dates
108
117
  # name.display_name(with_date: true) # => "King, Martin Luther, Jr., 1929-1968"
109
118
  def display_str(with_date: false)
110
- if dates_str.present? && with_date
119
+ if cocina["value"].present?
120
+ cocina["value"]
121
+ elsif display_name_str.present?
122
+ display_name_str
123
+ elsif dates_str.present? && with_date
111
124
  Utils.compact_and_join([full_name_str, dates_str], delimiter: ", ")
112
125
  else
113
126
  full_name_str
@@ -116,12 +129,10 @@ module CocinaDisplay
116
129
 
117
130
  private
118
131
 
119
- # The full name as a string.
120
- # If any names were marked as "display", prefer those.
121
- # Otherwise, combine all name components.
132
+ # The full name as a string, combining all name components.
122
133
  # @return [String]
123
134
  def full_name_str
124
- display_name_str.presence || Utils.compact_and_join(name_components, delimiter: ", ")
135
+ Utils.compact_and_join(name_components, delimiter: ", ")
125
136
  end
126
137
 
127
138
  # Flattened form of any names explicitly marked as "display name".
@@ -168,12 +179,56 @@ module CocinaDisplay
168
179
  # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#contributor-name-part-types-for-structured-value
169
180
  # @note Currently we do nothing with "alternative", "inverted full name", "pseudonym", and "transliteration" types.
170
181
  def name_values
171
- Utils.flatten_structured_values(cocina).each_with_object({}) do |node, hash|
182
+ Utils.flatten_nested_values(cocina).each_with_object({}) do |node, hash|
172
183
  type = node["type"] || "name"
173
184
  hash[type] ||= []
174
185
  hash[type] << node["value"]
175
186
  end.compact_blank
176
187
  end
177
188
  end
189
+
190
+ # A role associated with a contributor.
191
+ class Role
192
+ attr_reader :cocina
193
+
194
+ # Initialize a Role object with Cocina structured data.
195
+ # @param cocina [Hash] The Cocina structured data for the role.
196
+ def initialize(cocina)
197
+ @cocina = cocina
198
+ end
199
+
200
+ # The name of the role.
201
+ # Translates the MARC relator code if no value was present.
202
+ # @return [String, nil]
203
+ def display_str
204
+ cocina["value"] || (MARC_RELATOR[code] if marc_relator?)
205
+ end
206
+
207
+ # A code associated with the role, e.g. a MARC relator code.
208
+ # @return [String, nil]
209
+ def code
210
+ cocina["code"]
211
+ end
212
+
213
+ # Does this role indicate the contributor is an author?
214
+ # @return [Boolean]
215
+ def author?
216
+ display_str =~ /^(author|creator)/i
217
+ end
218
+
219
+ # Does this role indicate the contributor is a publisher?
220
+ # @return [Boolean]
221
+ def publisher?
222
+ display_str =~ /^publisher/i
223
+ end
224
+
225
+ private
226
+
227
+ # Does this role have a MARC relator code?
228
+ # @return [Boolean]
229
+ def marc_relator?
230
+ cocina.dig("source", "code") == "marcrelator"
231
+ end
232
+ end
178
233
  end
179
234
  end
@@ -82,9 +82,14 @@ module CocinaDisplay
82
82
 
83
83
  attr_reader :cocina, :date
84
84
 
85
+ # The type of this date, if any, such as "creation", "publication", etc.
86
+ # @return [String, nil]
87
+ attr_accessor :type
88
+
85
89
  def initialize(cocina)
86
90
  @cocina = cocina
87
91
  @date = self.class.parse_date(cocina["value"])
92
+ @type = cocina["type"] unless ["start", "end"].include?(cocina["type"])
88
93
  end
89
94
 
90
95
  # Compare this date to another {Date} or {DateRange} using its {sort_key}.
@@ -98,12 +103,6 @@ module CocinaDisplay
98
103
  cocina["value"]
99
104
  end
100
105
 
101
- # The type of this date, if any, such as "creation", "publication", etc.
102
- # @return [String, nil]
103
- def type
104
- cocina["type"]
105
- end
106
-
107
106
  # The qualifier for this date, if any, such as "approximate", "inferred", etc.
108
107
  # @return [String, nil]
109
108
  def qualifier
@@ -132,14 +131,16 @@ module CocinaDisplay
132
131
 
133
132
  # Is this the start date in a range?
134
133
  # @return [Boolean]
134
+ # @note The Cocina will mark start dates with "type": "start".
135
135
  def start?
136
- type == "start"
136
+ cocina["type"] == "start"
137
137
  end
138
138
 
139
139
  # Is this the end date in a range?
140
140
  # @return [Boolean]
141
+ # @note The Cocina will mark end dates with "type": "end".
141
142
  def end?
142
- type == "end"
143
+ cocina["type"] == "end"
143
144
  end
144
145
 
145
146
  # Was the date marked as approximate?
@@ -30,6 +30,7 @@ module CocinaDisplay
30
30
  @cocina = cocina
31
31
  @start = start
32
32
  @stop = stop
33
+ @type = cocina["type"]
33
34
  end
34
35
 
35
36
  # The values of the start and stop dates as an array.
@@ -83,6 +84,13 @@ module CocinaDisplay
83
84
  start&.parsed_date? || stop&.parsed_date? || false
84
85
  end
85
86
 
87
+ # False if both dates in the range have a known unparsable value like "9999".
88
+ # @see CocinaDisplay::Date#parsable?
89
+ # @return [Boolean]
90
+ def parsable?
91
+ start&.parsable? || stop&.parsable? || false
92
+ end
93
+
86
94
  # Decoded version of the range, if it was encoded. Strips leading zeroes.
87
95
  # @see CocinaDisplay::Date#decoded_value
88
96
  # @return [String]
@@ -0,0 +1,78 @@
1
+ require_relative "location"
2
+ require_relative "../dates/date"
3
+ require_relative "../contributor"
4
+
5
+ module CocinaDisplay
6
+ module Events
7
+ # An event associated with an object, like publication.
8
+ class Event
9
+ attr_reader :cocina
10
+
11
+ # Initialize the event with Cocina event data.
12
+ # @param cocina [Hash] Cocina structured data for a single event
13
+ def initialize(cocina)
14
+ @cocina = cocina
15
+ end
16
+
17
+ # The declared type of the event, like "publication" or "creation".
18
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#event-types
19
+ # @note This can differ from the contained date types.
20
+ # @return [String, nil]
21
+ def type
22
+ cocina["type"]
23
+ end
24
+
25
+ # All types of dates associated with this event.
26
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#event-date-types
27
+ # @note This can differ from the top-level event type.
28
+ # @return [Array<String>]
29
+ def date_types
30
+ dates.map(&:type).uniq
31
+ end
32
+
33
+ # True if either the event type or any date type matches the given type.
34
+ # @param match_type [String] The type to check against
35
+ # @return [Boolean]
36
+ def has_type?(match_type)
37
+ [type, *date_types].compact.include?(match_type)
38
+ end
39
+
40
+ # True if the event or its dates have any of the provided types.
41
+ # @param match_types [Array<String>] The types to check against
42
+ # @return [Boolean]
43
+ def has_any_type?(*match_types)
44
+ match_types.any? { |type| has_type?(type) }
45
+ end
46
+
47
+ # All dates associated with this event.
48
+ # Ignores known unparsable date values like "9999".
49
+ # If the date is untyped, uses this event's type as the date type.
50
+ # @note The date types may differ from the underlying event type.
51
+ # @return [Array<CocinaDisplay::Dates::Date>]
52
+ def dates
53
+ @dates ||= Array(cocina["date"]).filter_map do |date|
54
+ CocinaDisplay::Dates::Date.from_cocina(date)
55
+ end.filter(&:parsable?).map do |date|
56
+ date.type ||= type
57
+ date
58
+ end
59
+ end
60
+
61
+ # All contributors associated with this event.
62
+ # @return [Array<CocinaDisplay::Contributor>]
63
+ def contributors
64
+ @contributors ||= Array(cocina["contributor"]).map do |contributor|
65
+ CocinaDisplay::Contributor.new(contributor)
66
+ end
67
+ end
68
+
69
+ # All locations associated with this event.
70
+ # @return [Array<CocinaDisplay::Events::Location>]
71
+ def locations
72
+ @locations ||= Array(cocina["location"]).map do |location|
73
+ CocinaDisplay::Events::Location.new(location)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "edtf"
4
+ require "active_support"
5
+ require "active_support/core_ext/enumerable"
6
+ require "active_support/core_ext/object/blank"
7
+
8
+ require_relative "event"
9
+ require_relative "../utils"
10
+ require_relative "../marc_country_codes"
11
+ require_relative "../dates/date"
12
+ require_relative "../dates/date_range"
13
+
14
+ module CocinaDisplay
15
+ module Events
16
+ # Wrapper for Cocina events used to generate an imprint statement for display.
17
+ class Imprint < Event
18
+ # The entire imprint statement formatted as a string for display.
19
+ # @return [String]
20
+ def display_str
21
+ place_pub = Utils.compact_and_join([place_str, publisher_str], delimiter: " : ")
22
+ edition_place_pub = Utils.compact_and_join([edition_str, place_pub], delimiter: " - ")
23
+ Utils.compact_and_join([edition_place_pub, date_str], delimiter: ", ")
24
+ end
25
+
26
+ # Were any of the dates encoded?
27
+ # Used to detect which event(s) most likely represent the actual imprint(s).
28
+ def date_encoding?
29
+ dates.any?(&:encoding?)
30
+ end
31
+
32
+ private
33
+
34
+ # The date portion of the imprint statement, comprising all unique dates.
35
+ # @return [String]
36
+ def date_str
37
+ Utils.compact_and_join(unique_dates_for_display.map(&:qualified_value))
38
+ end
39
+
40
+ # The editions portion of the imprint statement, combining all edition notes.
41
+ # @return [String]
42
+ def edition_str
43
+ Utils.compact_and_join(Janeway.enum_for("$.note[?@.type == 'edition'].value", cocina))
44
+ end
45
+
46
+ # The place of publication, combining all location values.
47
+ # @return [String]
48
+ def place_str
49
+ Utils.compact_and_join(locations_for_display, delimiter: " : ")
50
+ end
51
+
52
+ # The publisher information, combining all name values for publishers.
53
+ # @return [String]
54
+ def publisher_str
55
+ Utils.compact_and_join(publishers.map(&:display_name), delimiter: " : ")
56
+ end
57
+
58
+ # All publishers associated with this imprint.
59
+ # @return [Array<CocinaDisplay::Contributor>]
60
+ # @see CocinaDisplay::Contributor#publisher?
61
+ def publishers
62
+ contributors.filter(&:publisher?)
63
+ end
64
+
65
+ # Filter dates for uniqueness using base value according to predefined rules.
66
+ # 1. For a group of dates with the same base value, choose a single one
67
+ # 2. Prefer unencoded dates over encoded ones when choosing a single date
68
+ # 3. Remove date ranges that duplicate any unencoded non-range dates
69
+ # @return [Array<CocinaDisplay::Dates::Date>]
70
+ # @see CocinaDisplay::Dates::Date#base_value
71
+ # @see https://consul.stanford.edu/display/chimera/MODS+display+rules#MODSdisplayrules-3b.%3CoriginInfo%3E
72
+ def unique_dates_for_display
73
+ # Choose a single date for each group with the same base value
74
+ deduped_dates = dates.group_by(&:base_value).map do |base_value, group|
75
+ if (unencoded = group.reject(&:encoding?)).any?
76
+ unencoded.first
77
+ else
78
+ group.first
79
+ end
80
+ end
81
+
82
+ # Remove any ranges that duplicate part of an unencoded non-range date
83
+ ranges, singles = deduped_dates.partition { |date| date.is_a?(CocinaDisplay::Dates::DateRange) }
84
+ unencoded_singles_dates = singles.reject(&:encoding?).flat_map(&:to_a)
85
+ ranges.reject! { |range| unencoded_singles_dates.any? { |date| range.as_interval.include?(date) } }
86
+
87
+ (singles + ranges).sort
88
+ end
89
+
90
+ # Filter locations to display according to predefined rules.
91
+ # 1. Prefer unencoded locations (plain value) over encoded ones
92
+ # 2. If no unencoded locations but there are MARC country codes, decode them
93
+ # 3. Keep only unique locations after decoding
94
+ def locations_for_display
95
+ unencoded_locs, encoded_locs = locations.partition { |loc| loc.unencoded_value? }
96
+ locs_for_display = unencoded_locs.presence || encoded_locs
97
+ locs_for_display.map(&:display_str).compact_blank.uniq
98
+ end
99
+ end
100
+ end
101
+ end