alma_course_loader 0.9.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,272 @@
1
+ require 'json'
2
+ require 'set'
3
+
4
+ module AlmaCourseLoader
5
+ # Methods for parsing filter specification strings
6
+ # The primary method is parse_filter(str, extractors)
7
+ module FilterParser
8
+ # The regular expression describing a filter string: [!][extractor[op]]value
9
+ FILTER = Regexp.new('\s*(?<negate>!)?\s*' \
10
+ '(' \
11
+ '(?<extractor>[^\s]*)\s+' \
12
+ '(?<method><=?|>=?|==|!=|~|=~|!~|keyin|valuein|in)\s+' \
13
+ ')?' \
14
+ '(?<value>.*)').freeze
15
+
16
+ # Filter operators mapped to method names
17
+ #
18
+ # The operators in filter expressions are converted to methods called on
19
+ # the filter value. Note that this means that the < and > operators are
20
+ # inverted ('field < filter-value' is equivalent to 'filter-value.>(field)')
21
+ # Operators not specified explicitly in FILTER_MAP are called directly as
22
+ # methods.
23
+ #
24
+ # The hash value is either:
25
+ # - a Symbol representing the method name to call on the filter value;
26
+ # - an array [method-name-symbol, negated] containing the method name symbol
27
+ # and a Boolean indicating whether the method is implicitly negated,
28
+ # e.g. !~ is !match(); the default is no implicit negation.
29
+ #
30
+ FILTER_MAP = {
31
+ :~ => :match,
32
+ :=~ => :match,
33
+ :!~ => [:match, true],
34
+ :< => :>,
35
+ :<= => :>=,
36
+ :>= => :<=,
37
+ :> => :<,
38
+ :in => :include?,
39
+ :keyin => :key?,
40
+ :valuein => :value?
41
+ }.freeze
42
+
43
+ # Parses a filter string and returns the parsed filter string components
44
+ # @param str [String] the filter string: [extractor][+|-]value
45
+ # where value is a JSON string specifying the value(s) to match,
46
+ # extractor is the optional name of an entry in the extractors hash,
47
+ # @param extractors [Hash<Symbol, Proc|Method>] a hash of named field
48
+ # extractors referenced by the filter string; this may include a nil
49
+ # key which specifies the default extractor if not given in the filter
50
+ # string
51
+ # @return [Array] the parsed filter string components:
52
+ # values, method, extractor, negate: [Object, Symbol, Proc, Boolean]
53
+ # @raise [ArgumentError] if the filter string is invalid
54
+ def parse_filter(str = nil, extractors = nil)
55
+ # Parse the filter string
56
+ raise ArgumentError, 'expected filter' if str.nil? || str.empty?
57
+ match = FILTER.match(str)
58
+ raise ArgumentError, "invalid filter: #{str}" if match.nil?
59
+ # Get the filter value(s)
60
+ value = Private.parse_value(match)
61
+ # Get the method used to compare the filter value(s)
62
+ method, method_negate = parse_method(match, value)
63
+ # Get the effective negate flag
64
+ negate = Private.negate_flag(match[:negate] == '!', method_negate)
65
+ # Get the named extractor from the hash or block
66
+ extractor = Private.parse_extractor(match, extractors)
67
+ # Return the parsed filter string components
68
+ [value, method, extractor, negate]
69
+ end
70
+
71
+ # Validates and returns the method symbol and negation flag
72
+ # @param match [MatchData] the parsed filter string
73
+ # @param values [Object] the filter value(s)
74
+ # @return [Array] the method symbol and negate flag
75
+ # @raise [ArgumentError] if the specified method is not supported by the
76
+ # filter values
77
+ def parse_method(match, values)
78
+ method_info = Private.method_symbol(match, values)
79
+ method = method_info[0]
80
+ unless values.respond_to?(method)
81
+ raise ArgumentError, "invalid method: #{values.class.name}\##{method}"
82
+ end
83
+ method_info
84
+ end
85
+
86
+ # Private helpers
87
+ module Private
88
+ # Returns true if the value parameter is a collection
89
+ # @param value [Object] the value to test
90
+ # @return [Boolean] true if the value is a collection, false otherwise
91
+ def self.collection?(value)
92
+ value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Set)
93
+ end
94
+
95
+ # Returns a Proc implementing the filter match
96
+ # @param values [Object] the filter values
97
+ # @param method [Symbol] the method to call against the filter values
98
+ # @param negate [Boolean] if true, negate the result of the method
99
+ # @return [Proc] the Proc implementing the filter
100
+ def self.matcher(values, method = nil, negate = false)
101
+ method, negate = method_symbol(method, values) if method.nil?
102
+ proc do |value|
103
+ result = values.send(method, value)
104
+ negate ? !result : result
105
+ end
106
+ end
107
+
108
+ # Returns the method symbol or appropriate default if not specified
109
+ # If no method is specified in the filter string, the default is :include?
110
+ # for Arrays, Hashes and Sets, :match for regular expressions and :== for
111
+ # everything else.
112
+ # @param match [MatchData] the parsed filter string
113
+ # @param values [Object] the filter value(s)
114
+ # @return [Array] the method symbol and negation flag (false|true)
115
+ def self.method_symbol(match, values)
116
+ method = match[:method]
117
+ # Return the default if no method is specified
118
+ return method_symbol_default(values) if method.nil? || method.empty?
119
+ method = method.to_sym
120
+ # Return the method as specified unless it's mapped
121
+ return method, false unless FILTER_MAP.key?(method)
122
+ # Otherwise return the mapped filter
123
+ method, negate = FILTER_MAP[method]
124
+ [method, negate.nil? ? false : negate]
125
+ end
126
+
127
+ # Returns a default method symbol based on the filter value type
128
+ # @param values [Object] the filter value(s)
129
+ # @return [Array] the default method symbol and negation flag (false|true)
130
+ def self.method_symbol_default(values)
131
+ return :include?, false if collection?(values)
132
+ return :match, false if values.is_a?(Regexp)
133
+ [:==, false]
134
+ end
135
+
136
+ # Returns the actual filter negate flag based on the requested filter
137
+ # negate flag and the compare method's negate flag
138
+ # @param filter_negate [Boolean] the requested filter negate flag
139
+ # @param method_negate [Boolean] the compare method's negate flag
140
+ # @return [Boolean] the effective negate flag
141
+ def self.negate_flag(filter_negate = false, method_negate = false)
142
+ # The effective negate flag is true only if exactly one of the arguments
143
+ # is true (filter_flag xor method_negate) since double negation cancels
144
+ filter_negate != method_negate
145
+ end
146
+
147
+ # Returns the extractor Proc specified by a parsed filter string
148
+ # @param match [MatchData] the parsed filter string
149
+ # @param extractors [Hash<Symbol, Proc|Method>] the extractors
150
+ # @raise [ArgumentError] if a specified extractor is invalid or there is
151
+ # no default extractor in extractors when required
152
+ def self.parse_extractor(match, extractors = nil)
153
+ raise ArgumentError, 'extractors required' \
154
+ if extractors.nil? || extractors.empty?
155
+ e = match[:extractor]
156
+ extractor = extractors[e ? e.to_sym : nil]
157
+ return extractor unless extractor.nil?
158
+ raise ArgumentError, "invalid extractor: #{e}" if e
159
+ raise ArgumentError, 'no default extractor'
160
+ end
161
+
162
+ # Returns a parsed regular expression
163
+ # @param regexp [String] the regular expression from the value string
164
+ # @return [Regexp] the parsed regular expression
165
+ # @raise [ArgumentError] if the regular expression cannot be parsed
166
+ def self.parse_regexp(regexp)
167
+ # Remove enclosing / if present
168
+ first = regexp.start_with?('/') ? 1 : 0
169
+ last = regexp.end_with?('/') ? -2 : -1
170
+ Regexp.new(regexp[first..last])
171
+ rescue RegexpError
172
+ raise ArgumentError, "invalid regular expression: #{regexp}"
173
+ end
174
+
175
+ # Returns the parsed value string
176
+ # @param match [MatchData] the parsed filter string
177
+ # @return [Object] the filter value(s)
178
+ # @raise [ArgumentError] if the value string cannot be parsed
179
+ def self.parse_value(match)
180
+ value = match[:value]
181
+ return nil if value.nil?
182
+ # Parse strings starting with / as regular expressions
183
+ return parse_regexp(value) if value.start_with?('/')
184
+ # Parse all other strings as JSON
185
+ JSON.parse(value)
186
+ rescue JSON::ParserError
187
+ raise ArgumentError, "invalid value: #{value}"
188
+ end
189
+ end
190
+ end
191
+
192
+ # Implements a course filter
193
+ class Filter
194
+ extend FilterParser
195
+
196
+ # @!attribute [rw] extractor
197
+ # @return [Proc, Method] a block accepting a year, course and cohort and
198
+ # returning the value used in filter matching
199
+ attr_accessor :extractor
200
+
201
+ # @!attribute [rw] method
202
+ # @return [Symbol] the method to call against the values
203
+ attr_accessor :method
204
+
205
+ # @!attribute [rw] negate
206
+ # @return [Boolean] if true, the filter result is negated
207
+ attr_accessor :negate
208
+
209
+ # @!attribute [rw] values
210
+ # @return [Object] the value or collection to match against
211
+ attr_accessor :values
212
+
213
+ # Parses a filter string and returns a Filter instance
214
+ # @param str [String] the filter string: [extractor][+|-]value
215
+ # where value is a JSON string specifying the value(s) to match,
216
+ # extractor is the optional name of an entry in the extractors hash,
217
+ # @param extractors [Hash<Symbol, Proc|Method>] a hash of named field
218
+ # extractors referenced by the filter string; this may include a nil
219
+ # key which specifies the default extractor if not given in the filter
220
+ # string
221
+ # @raise [ArgumentError] if the filter string is invalid
222
+ def self.parse(str = nil, extractors = nil)
223
+ new(*parse_filter(str, extractors))
224
+ end
225
+
226
+ # Initialises a new Filter instance
227
+ # @param values [Object] the value or collection to match against
228
+ # @param method [Symbol] the method symbol applied to the filter values
229
+ # @param extractor [Proc, Method] a block accepting a year, course and
230
+ # cohort and returning the value used in filter matching.
231
+ # This may be also passed as a code block.
232
+ # @param negate [Boolean] if true, negate the result of the filter
233
+ # @raise [ArgumentError]
234
+ # @return [void]
235
+ def initialize(values = nil, method = nil, extractor = nil, negate = false,
236
+ &block)
237
+ self.method = method
238
+ self.negate = negate
239
+ self.extractor = extractor || block
240
+ self.values = values
241
+ @matcher = matcher_proc(method)
242
+ end
243
+
244
+ # Applies the filter to a course/cohort
245
+ # @param year [Object] the course year
246
+ # @param course [Object] the course
247
+ # @param cohort [Object] the course cohort
248
+ # @return [Boolean] true if the course passes the filter, false otherwise
249
+ def call(year = nil, course = nil, cohort = nil)
250
+ # Get the value for matching
251
+ value = extractor.call(year, course, cohort)
252
+ # Test against the filter values (true => match, false => no match)
253
+ result = @matcher.call(value)
254
+ # Return the result with appropriate negation
255
+ negate ? !result : result
256
+ end
257
+
258
+ private
259
+
260
+ # Returns a Proc instance which calls the specified method on the filter
261
+ # value object
262
+ # @param method [Method, Proc, Symbol] the method (Method/Proc arguments are
263
+ # returned as-is)
264
+ # @return [Proc] the Proc instance
265
+ def matcher_proc(method)
266
+ # Return Method/Proc arguments unchanged
267
+ return method if method.is_a?(Method) || method.is_a?(Proc)
268
+ # Otherwise return a Proc which invokes method on the filter values
269
+ proc { |value| values.send(method, value) }
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,298 @@
1
+ require 'alma_course_loader/version'
2
+
3
+ module AlmaCourseLoader
4
+ # The abstract base class for course readers.
5
+ #
6
+ # A course reader is responsible for reading course data from a data source
7
+ # and iterating over each course element. A course element is either the
8
+ # course itself or, if the course is divided into cohorts, a single cohort
9
+ # of the course.
10
+ #
11
+ # It is expected that courses are retrieved by year, and the iterators accept
12
+ # a list of years for which courses are required. The implementation and
13
+ # type of years, courses and cohorts is deferred to the subclasses.
14
+ #
15
+ # Filters may be defined which determine whether a single course element is
16
+ # processed or ignored. The course element must pass all filters before it
17
+ # is processed.
18
+ #
19
+ # The years and filters controlling course element iteration can be passed
20
+ # to the constructor, which defines defaults for subsequent iterators, or to
21
+ # the #each method, which defines the criteria for that iteration. When
22
+ # creating a reader which will be passed to a writer, always define the
23
+ # iterator criteria in the reader constructor, as the writer uses the
24
+ # default iterator.
25
+ #
26
+ # There are two classes of iterators. The first invokes its block with the
27
+ # course details: yield(year, course, cohort, instructors)
28
+ # The second, used by the Writer class, invokes its block with an array of
29
+ # data suitable for writing as a row in a course loader CSV file: yield(row)
30
+ #
31
+ # @abstract Subclasses must implement the following methods:
32
+ # #courses
33
+ # returns a list of course objects
34
+ # #course_cohorts(course)
35
+ # returns a list of cohorts for the course or nil if cohorts are not used
36
+ # #current_academic_year
37
+ # returns the current academic year
38
+ # #instructors(year, course, cohort)
39
+ # returns a list of instructors for the course
40
+ # #row_data(array, year, course, cohort, instructors)
41
+ # configures an array of formatted course information suitable for writing
42
+ # to an Alma course loader CSV file
43
+ #
44
+ # @example Create course filters
45
+ # # Define extractors to retrieve values from a course element for matching
46
+ # # Extractors are called with the year, course and cohort arguments
47
+ # get_code = proc { |year, course, cohort| course.code }
48
+ # get_title = proc { |year, course, cohort| course.title }
49
+ #
50
+ # # Define a list of course codes and a regular expression matching titles
51
+ # codes = ['COMPSCI101', 'PHYSICS101', 'MAGIC101']
52
+ # titles = Regexp.compile('dissertation|(extended|ma) essay|test', true)
53
+ #
54
+ # # Define a filter which passes only the specified course codes
55
+ # include_codes = AlmaCourseLoader::Filter.new(codes, :include, get_code)
56
+ #
57
+ # # Define a filter which passes all codes except those specified
58
+ # exclude_codes = AlmaCourseLoader::Filter.new(codes, :exclude, get_code)
59
+ #
60
+ # # Define a filter which passes all titles matching the regular expression
61
+ # include_titles = AlmaCourseLoader::Filter.new(titles, :include, get_title)
62
+ #
63
+ # # Define a filter which passes all titles except those matching the regexp
64
+ # exclude_titles = AlmaCourseLoader::Filter.new(titles, :exclude, get_title)
65
+ #
66
+ # @example Create a reader with default selection criteria
67
+ # # Filters are passed in an array, course elements must pass all filters
68
+ # reader = Reader.new(2016, 2017, filters: [exclude_codes, include_titles])
69
+ #
70
+ # @example Iterate course elements using default selection criteria
71
+ # reader.each { |year, course, cohort, instructors| ... }
72
+ #
73
+ # @example Iterate course elements using specific selection criteria
74
+ # # Use an empty array to override filters. filters: nil will use the
75
+ # # default filters.
76
+ # reader.each(2012, filters: []) { |year, course, cohort, instructors| ... }
77
+ #
78
+ # @example Iterate course CSV rows
79
+ # reader.each_row { |row_array| ... }
80
+ # reader.each_row(2012, filters: []) { |row_array| ... }
81
+ #
82
+ class Reader
83
+ # Initialises a new Reader instance
84
+ # Positional parameters are the default years to iterate over
85
+ # @param current_year [Object] the current academic year
86
+ # @param filters [Array<AlmaCourseLoader::Filter>] default course filters
87
+ def initialize(*years, current_year: nil, filters: nil)
88
+ @current_academic_year = current_year || current_academic_year
89
+ @filters = filters
90
+ @years = years.nil? || years.empty? ? [@current_academic_year] : years
91
+ end
92
+
93
+ # Iterate over the courses for specified years. Only courses which pass
94
+ # the filters are passed to the block.
95
+ # Positional parameters are years
96
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
97
+ # @return [void]
98
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
99
+ # block
100
+ # @yieldparam year [Object] the course year
101
+ # @yieldparam course [Object] the course
102
+ # @yieldparam cohort [Object] the course cohort
103
+ # @yieldparam instructors [Array<Object>] the course instructors
104
+ def each(*years, filters: nil, &block)
105
+ # Process courses for each year
106
+ years = @years if years.nil? || years.empty?
107
+ years.each { |year| each_course_in_year(year, filters: filters, &block) }
108
+ nil
109
+ end
110
+
111
+ # Iterates over the cohorts in a course, or just the course itself if
112
+ # cohort processing is disabled. Only courses/cohorts which pass the filters
113
+ # are passed to the block.
114
+ # @param year [Object] the course year
115
+ # @param course [Object] the course
116
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
117
+ # @return [void]
118
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
119
+ # block
120
+ # @yieldparam year [Object] the course year
121
+ # @yieldparam course [Object] the course
122
+ # @yieldparam cohort [Object] the course cohort
123
+ # @yieldparam instructors [Array<Object>] the course instructors
124
+ def each_cohort_in_course(year, course, filters: nil, &block)
125
+ cohorts = course_cohorts(year, course)
126
+ if cohorts.nil?
127
+ # Process the course
128
+ process_course(year, course, nil, filters, &block)
129
+ else
130
+ cohorts.each do |cohort|
131
+ # Process each cohort of the course
132
+ process_course(year, course, cohort, filters, &block)
133
+ end
134
+ end
135
+ end
136
+
137
+ # Iterates over the cohorts in a course, or just the course itself if
138
+ # cohort processing is disabled. Only courses/cohorts which pass the filters
139
+ # are passed to the block.
140
+ # @param year [Object] the course year
141
+ # @param course [Object] the course
142
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
143
+ # @return [void]
144
+ # @yield [row] Passes the course to the block
145
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
146
+ def each_cohort_in_course_row(year, course, filters: nil, &block)
147
+ each_cohort_in_course(year, course, filters: filters) do |*args|
148
+ row(*args, &block)
149
+ end
150
+ end
151
+
152
+ # Iterate over the courses for the specified year. Only courses which pass
153
+ # the filters are passed to the block.
154
+ # @param year [Object] the year
155
+ # @param filters [Array<AlmaCourseLoader::Filter>] filters selecting courses
156
+ # @return [void]
157
+ # @yield [year, course, cohort, year, instructors] Passes the course to the
158
+ # block
159
+ # @yieldparam year [Object] the course year
160
+ # @yieldparam course [Object] the course
161
+ # @yieldparam cohort [Object] the course cohort
162
+ # @yieldparam instructors [Array<Object>] the course instructors
163
+ def each_course_in_year(year, filters: nil, &block)
164
+ # Simplify the test for filter existence
165
+ filters ||= @filters
166
+ filters = nil if filters.is_a?(Array) && filters.empty?
167
+ # Get all courses for the year
168
+ courses(year).each do |course|
169
+ each_cohort_in_course(year, course, filters: filters, &block)
170
+ end
171
+ nil
172
+ end
173
+
174
+ # Iterate over the courses for the specified year. Only courses which pass
175
+ # the filters are passed to the block.
176
+ # @param year [Object] the year
177
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
178
+ # @return [void]
179
+ # @yield [row] Passes the course to the block
180
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
181
+ def each_course_in_year_row(year, filters: nil, &block)
182
+ each_course_in_year(year, filters: filters) { |*args| row(*args, &block) }
183
+ end
184
+
185
+ # Iterate over the courses for specified years. Only courses which pass
186
+ # the filters are passed to the block.
187
+ # Positional parameters are years
188
+ # @param filters [Array<AlmaCourseLoader::Filter>] course filters
189
+ # @return [void]
190
+ # @yield [row] Passes the course to the block
191
+ # @yieldparam row [Array<String>] the course as a CSV row (array)
192
+ def each_row(*years, filters: nil, &block)
193
+ each(*years, filters: filters) { |*args| row(*args, &block) }
194
+ end
195
+
196
+ protected
197
+
198
+ # Returns a list of courses for the specified year
199
+ # @abstract Subclasses must implement this method.
200
+ # @param year [Object] the course year
201
+ # @return [Array<Object>] the courses for the year
202
+ def courses(year)
203
+ []
204
+ end
205
+
206
+ # Returns a list of cohorts for the specified course
207
+ # @abstract Subclasses should implement this method. If courses are not
208
+ # divided into cohorts, this method must return nil.
209
+ # @param course [Object] the course
210
+ # @return [Array<Object>, nil] the cohorts for the course, or nil to disable
211
+ # cohort processing
212
+ def course_cohorts(year, course)
213
+ nil
214
+ end
215
+
216
+ # Returns the current academic year
217
+ # @abstract Subclasses should implement this method
218
+ # @return [Object] the current academic year
219
+ def current_academic_year
220
+ nil
221
+ end
222
+
223
+ # Applies filters to the course/cohort to determine whether to process
224
+ # @param year [Object] the course year
225
+ # @param course [Object] the course
226
+ # @param cohort [Object] the course cohort
227
+ # @return [Boolean] true if the course is to be processed, false if not
228
+ def filter(year, course, cohort, filters)
229
+ # Return true if no filters are specified
230
+ return true if filters.nil? || filters.empty?
231
+ # Return false if any of the filters returns false
232
+ filters.each { |f| return false unless f.call(year, course, cohort) }
233
+ # At this point all filters returned true
234
+ true
235
+ end
236
+
237
+ # Returns a list of instructors for the course/cohort
238
+ # @abstract Subclasses should implement this method
239
+ # @param year [Object] the course year
240
+ # @param course [Object] the course
241
+ # @param cohort [Object] the course cohort
242
+ # @return [Array<Object>] the course instructors
243
+ def instructors(year, course, cohort)
244
+ []
245
+ end
246
+
247
+ # Filters and processes a course
248
+ # @param year [Object] the course year
249
+ # @param course [Object] the course
250
+ # @param cohort [Object] the LUSI course cohort
251
+ # @param filters [Array<AlmaCourseLoader::Filter>] the course filters
252
+ # @return [Boolean] true if the course was processed, false otherwise
253
+ # @yield Passes the course to the block
254
+ # @yieldparam year [Object] the course year
255
+ # @yieldparam course [Object] the course
256
+ # @yieldparam cohort [Object] the course cohort
257
+ # @yieldparam instructors [Array<Object>] the course instructors
258
+ def process_course(year, course, cohort = nil, filters = nil)
259
+ # The course must pass all filters
260
+ return false unless filter(year, course, cohort, filters)
261
+ # Get the course instructors
262
+ course_instructors = instructors(year, course, cohort)
263
+ # Pass the details to the block
264
+ yield(year, course, cohort, course_instructors) if block_given?
265
+ # Indicate that the course was processed
266
+ true
267
+ end
268
+
269
+ # Returns a CSV row (array) for a specific course/cohort
270
+ # @param year [Object] the course year
271
+ # @param course [Object] the course
272
+ # @param cohort [Object] the course cohort
273
+ # @param instructors [Array<Object>] the
274
+ # course instructor enrolments
275
+ # @yield Passes the CSV row array to the block
276
+ # @yieldparam row [Array<String>] the CSV row array
277
+ def row(year = nil, course = nil, cohort = nil, instructors = nil)
278
+ # Create and populate the CSV row
279
+ data = Array.new(31)
280
+ row_data(data, year, course, cohort, instructors)
281
+ # Pass the row to the block
282
+ yield(data) if block_given?
283
+ # Return the row
284
+ data
285
+ end
286
+
287
+ # Populates the CSV row (array) for a specific course/cohort
288
+ # @abstract Subclasses must implement this method
289
+ # @param data [Array<String>] the CSV row (array)
290
+ # @param year [Object] the course year
291
+ # @param course [Object] the course
292
+ # @param cohort [Object] the course cohort
293
+ # @param instructors [Array<Object>] the course instructors
294
+ def row_data(data, year, course, cohort, instructors)
295
+ data
296
+ end
297
+ end
298
+ end