csv_plus_plus 0.0.2

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/lib/csv_plus_plus/cell.rb +51 -0
  3. data/lib/csv_plus_plus/code_section.rb +49 -0
  4. data/lib/csv_plus_plus/color.rb +22 -0
  5. data/lib/csv_plus_plus/expand.rb +18 -0
  6. data/lib/csv_plus_plus/google_options.rb +23 -0
  7. data/lib/csv_plus_plus/graph.rb +68 -0
  8. data/lib/csv_plus_plus/language/cell_value.tab.rb +333 -0
  9. data/lib/csv_plus_plus/language/code_section.tab.rb +443 -0
  10. data/lib/csv_plus_plus/language/compiler.rb +170 -0
  11. data/lib/csv_plus_plus/language/entities/boolean.rb +32 -0
  12. data/lib/csv_plus_plus/language/entities/cell_reference.rb +26 -0
  13. data/lib/csv_plus_plus/language/entities/entity.rb +70 -0
  14. data/lib/csv_plus_plus/language/entities/function.rb +33 -0
  15. data/lib/csv_plus_plus/language/entities/function_call.rb +25 -0
  16. data/lib/csv_plus_plus/language/entities/number.rb +34 -0
  17. data/lib/csv_plus_plus/language/entities/runtime_value.rb +27 -0
  18. data/lib/csv_plus_plus/language/entities/string.rb +29 -0
  19. data/lib/csv_plus_plus/language/entities/variable.rb +25 -0
  20. data/lib/csv_plus_plus/language/entities.rb +28 -0
  21. data/lib/csv_plus_plus/language/references.rb +53 -0
  22. data/lib/csv_plus_plus/language/runtime.rb +147 -0
  23. data/lib/csv_plus_plus/language/scope.rb +199 -0
  24. data/lib/csv_plus_plus/language/syntax_error.rb +61 -0
  25. data/lib/csv_plus_plus/lexer/lexer.rb +64 -0
  26. data/lib/csv_plus_plus/lexer/tokenizer.rb +65 -0
  27. data/lib/csv_plus_plus/lexer.rb +14 -0
  28. data/lib/csv_plus_plus/modifier.rb +124 -0
  29. data/lib/csv_plus_plus/modifier.tab.rb +921 -0
  30. data/lib/csv_plus_plus/options.rb +70 -0
  31. data/lib/csv_plus_plus/row.rb +42 -0
  32. data/lib/csv_plus_plus/template.rb +61 -0
  33. data/lib/csv_plus_plus/version.rb +6 -0
  34. data/lib/csv_plus_plus/writer/base_writer.rb +21 -0
  35. data/lib/csv_plus_plus/writer/csv.rb +31 -0
  36. data/lib/csv_plus_plus/writer/excel.rb +13 -0
  37. data/lib/csv_plus_plus/writer/google_sheet_builder.rb +173 -0
  38. data/lib/csv_plus_plus/writer/google_sheets.rb +139 -0
  39. data/lib/csv_plus_plus/writer/open_document.rb +14 -0
  40. data/lib/csv_plus_plus/writer.rb +25 -0
  41. data/lib/csv_plus_plus.rb +20 -0
  42. metadata +83 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './google_options'
4
+
5
+ module CSVPlusPlus
6
+ # The options a user can supply
7
+ class Options
8
+ attr_accessor :backup, :create_if_not_exists, :key_values, :offset, :output_filename, :sheet_name, :verbose
9
+ attr_reader :google
10
+
11
+ # initialize
12
+ def initialize
13
+ @offset = [0, 0]
14
+ @create_if_not_exists = false
15
+ @key_values = {}
16
+ @verbose = false
17
+ # TODO: switch to true? probably a safer choice
18
+ @backup = false
19
+ end
20
+
21
+ # Set the Google Sheet ID
22
+ def google_sheet_id=(sheet_id)
23
+ @google = ::CSVPlusPlus::GoogleOptions.new(sheet_id)
24
+ end
25
+
26
+ # Returns an error string or nil if there are no validation problems
27
+ def validate
28
+ return if @google || @output_filename
29
+
30
+ 'You must supply either a Google Sheet ID or an output file'
31
+ end
32
+
33
+ # Return a string with a verbose description of what we're doing with the options
34
+ def verbose_summary
35
+ <<~SUMMARY
36
+ #{summary_divider}
37
+
38
+ # csv++ Command Options
39
+
40
+ > Input filename | #{@filename}
41
+ > Sheet name | #{@sheet_name}
42
+ > Create sheet if it does not exist? | #{@create_if_not_exists}
43
+ > Spreadsheet row-offset | #{@offset[0]}
44
+ > Spreadsheet cell-offset | #{@offset[1]}
45
+ > User-supplied key-values | #{@key_values}
46
+ > Verbose | #{@verbose}
47
+
48
+ ## Output Options
49
+
50
+ > Backup | #{@backup}
51
+ > Output filename | #{@output_filename}
52
+
53
+ #{@google&.verbose_summary || ''}
54
+ #{summary_divider}
55
+ SUMMARY
56
+ end
57
+
58
+ # to_s
59
+ def to_s
60
+ "Options(create_if_not_exists: #{@create_if_not_exists}, google: #{@google}, key_values: #{@key_values}, " \
61
+ "offset: #{@offset}, sheet_name: #{@sheet_name}, verbose: #{@verbose})"
62
+ end
63
+
64
+ private
65
+
66
+ def summary_divider
67
+ '========================================================================='
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cell'
4
+ require_relative 'modifier.tab'
5
+
6
+ module CSVPlusPlus
7
+ ##
8
+ # A row of a template
9
+ class Row
10
+ attr_reader :cells, :index, :modifier
11
+
12
+ # initialize
13
+ def initialize(index, cells, modifier)
14
+ @cells = cells
15
+ @modifier = modifier
16
+ @index = index
17
+ end
18
+
19
+ # Set the row index. And update the index of all affected cells
20
+ def index=(index)
21
+ @index = index
22
+ @cells.each { |cell| cell.row_index = index }
23
+ end
24
+
25
+ # How much this row will expand itself, if at all (0)
26
+ def expand_amount
27
+ return 0 unless @modifier.expand
28
+
29
+ @modifier.expand.repetitions || (1000 - @index)
30
+ end
31
+
32
+ # to_s
33
+ def to_s
34
+ "Row(index: #{index}, modifier: #{modifier}, cells: #{cells})"
35
+ end
36
+
37
+ # Return a deep copy of this row
38
+ def deep_clone
39
+ ::Marshal.load(::Marshal.dump(self))
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ # Contains the flow and data from a code section and CSV section
5
+ class Template
6
+ attr_reader :rows, :scope
7
+
8
+ # initialize
9
+ def initialize(rows:, scope:)
10
+ @rows = rows
11
+ @scope = scope
12
+ end
13
+
14
+ # to_s
15
+ def to_s
16
+ "Template(rows: #{@rows}, scope: #{@scope})"
17
+ end
18
+
19
+ # Apply any expand= modifiers to the parsed template
20
+ def expand_rows!
21
+ expanded_rows = []
22
+ row_index = 0
23
+ expand_rows(
24
+ lambda do |new_row|
25
+ new_row.index = row_index
26
+ expanded_rows << new_row
27
+ row_index += 1
28
+ end
29
+ )
30
+
31
+ @rows = expanded_rows
32
+ end
33
+
34
+ # Make sure that the template has a valid amount of infinite expand modifiers
35
+ def validate_infinite_expands(runtime)
36
+ infinite_expand_rows = @rows.filter { |r| r.modifier.expand&.infinite? }
37
+ return unless infinite_expand_rows.length > 1
38
+
39
+ runtime.raise_syntax_error(
40
+ 'You can only have one infinite expand= (on all others you must specify an amount)',
41
+ infinite_expand_rows[1]
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def expand_rows(push_row_fn)
48
+ # TODO: make it so that an infinite expand will not overwrite the rows below it, but
49
+ # instead merge with them
50
+ rows.each do |row|
51
+ if row.modifier.expand
52
+ row.expand_amount.times do
53
+ push_row_fn.call(row.deep_clone)
54
+ end
55
+ else
56
+ push_row_fn.call(row)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ VERSION = '0.0.2'
5
+ public_constant :VERSION
6
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ ##
6
+ # Some shared functionality that all Writers should build on
7
+ class BaseWriter
8
+ attr_accessor :options
9
+
10
+ protected
11
+
12
+ # Open a CSV outputter to +filename+
13
+ def initialize(options)
14
+ @options = options
15
+ load_requires
16
+ end
17
+
18
+ def load_requires; end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ ##
6
+ # A class that can output a +Template+ to CSV
7
+ class CSV < ::CSVPlusPlus::Writer::BaseWriter
8
+ # write a +template+ to CSV
9
+ def write(template)
10
+ # TODO: also read it and merge the results
11
+ ::CSV.open(@options.output_filename, 'wb') do |csv|
12
+ template.rows.each do |row|
13
+ csv << build_row(row)
14
+ end
15
+ end
16
+ end
17
+
18
+ protected
19
+
20
+ def load_requires
21
+ require('csv')
22
+ end
23
+
24
+ private
25
+
26
+ def build_row(row)
27
+ row.cells.map(&:to_csv)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ # A class that can output a +Template+ to an Excel file
6
+ class Excel < ::CSVPlusPlus::Writer::BaseWriter
7
+ # write a +template+ to an Excel file
8
+ def write(template)
9
+ # TODO
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ ##
6
+ # Given +rows+ from a +Template+, build requests compatible with Google Sheets Ruby API
7
+ # rubocop:disable Metrics/ClassLength
8
+ class GoogleSheetBuilder
9
+ # initialize
10
+ def initialize(current_sheet_values:, sheet_id:, rows:, column_index: 0, row_index: 0)
11
+ @current_sheet_values = current_sheet_values
12
+ @sheet_id = sheet_id
13
+ @rows = rows
14
+ @column_index = column_index
15
+ @row_index = row_index
16
+ end
17
+
18
+ # Build a Google::Apis::SheetsV4::BatchUpdateSpreadsheetRequest
19
+ def batch_update_spreadsheet_request
20
+ build_batch_request(@rows)
21
+ end
22
+
23
+ private
24
+
25
+ def sheets_ns
26
+ ::Google::Apis::SheetsV4
27
+ end
28
+
29
+ def sheets_color(color)
30
+ sheets_ns::Color.new(red: color.red, green: color.green, blue: color.blue)
31
+ end
32
+
33
+ def set_extended_value_type!(extended_value, value)
34
+ v = value || ''
35
+ if v.start_with?('=')
36
+ extended_value.formula_value = value
37
+ elsif v.match(/^-?[\d.]+$/)
38
+ extended_value.number_value = value
39
+ elsif v.downcase == 'true' || v.downcase == 'false'
40
+ extended_value.boolean_value = value
41
+ else
42
+ extended_value.string_value = value
43
+ end
44
+ end
45
+
46
+ def build_text_format(mod)
47
+ sheets_ns::TextFormat.new(
48
+ bold: mod.formatted?('bold') || nil,
49
+ italic: mod.formatted?('italic') || nil,
50
+ strikethrough: mod.formatted?('strikethrough') || nil,
51
+ underline: mod.formatted?('underline') || nil,
52
+
53
+ font_family: mod.fontfamily,
54
+ font_size: mod.fontsize,
55
+
56
+ foreground_color: mod.fontcolor ? sheets_color(mod.fontcolor) : nil
57
+ )
58
+ end
59
+
60
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
61
+ def build_cell_format(mod)
62
+ sheets_ns::CellFormat.new.tap do |cf|
63
+ cf.text_format = build_text_format(mod)
64
+
65
+ # TODO: are these not overwriting each other?
66
+ cf.horizontal_alignment = 'LEFT' if mod.aligned?('left')
67
+ cf.horizontal_alignment = 'RIGHT' if mod.aligned?('right')
68
+ cf.horizontal_alignment = 'CENTER' if mod.aligned?('center')
69
+ cf.vertical_alignment = 'TOP' if mod.aligned?('top')
70
+ cf.vertical_alignment = 'BOTTOM' if mod.aligned?('bottom')
71
+
72
+ cf.background_color = sheets_color(mod.color) if mod.color
73
+
74
+ cf.number_format = sheets_ns::NumberFormat.new(type: mod.numberformat) if mod.numberformat
75
+ end
76
+ end
77
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
78
+
79
+ def grid_range_for_cell(cell)
80
+ sheets_ns::GridRange.new(
81
+ sheet_id: @sheet_id,
82
+ start_column_index: cell.index,
83
+ end_column_index: cell.index + 1,
84
+ start_row_index: cell.row_index,
85
+ end_row_index: cell.row_index + 1
86
+ )
87
+ end
88
+
89
+ def current_value(row_index, cell_index)
90
+ @current_values[row_index][cell_index]
91
+ rescue ::StandardError
92
+ nil
93
+ end
94
+
95
+ def build_cell_value(cell)
96
+ sheets_ns::ExtendedValue.new.tap do |xv|
97
+ value =
98
+ if cell.value.nil?
99
+ current_value(cell.row_index, cell.index)
100
+ else
101
+ cell.to_csv
102
+ end
103
+
104
+ set_extended_value_type!(xv, value)
105
+ end
106
+ end
107
+
108
+ def build_cell_data(cell)
109
+ mod = cell.modifier
110
+
111
+ sheets_ns::CellData.new.tap do |cd|
112
+ cd.user_entered_format = build_cell_format(cell.modifier)
113
+ cd.note = mod.note if mod.note
114
+
115
+ # XXX apply data validation
116
+ cd.user_entered_value = build_cell_value(cell)
117
+ end
118
+ end
119
+
120
+ def build_row_data(row)
121
+ sheets_ns::RowData.new(values: row.cells.map { |cell| build_cell_data(cell) })
122
+ end
123
+
124
+ def build_update_cells_request(rows)
125
+ sheets_ns::UpdateCellsRequest.new(
126
+ fields: '*',
127
+ start: sheets_ns::GridCoordinate.new(
128
+ sheet_id: @sheet_id,
129
+ column_index: @column_index,
130
+ row_index: @row_index
131
+ ),
132
+ rows: rows.map { |row| build_row_data(row) }
133
+ )
134
+ end
135
+
136
+ def build_border(cell)
137
+ mod = cell.modifier
138
+ # TODO: allow different border styles per side
139
+ border = sheets_ns::Border.new(color: mod.bordercolor || '#000000', style: mod.borderstyle || 'solid')
140
+ sheets_ns::UpdateBordersRequest.new(
141
+ top: mod.border_along?('top') ? border : nil,
142
+ right: mod.border_along?('right') ? border : nil,
143
+ left: mod.border_along?('left') ? border : nil,
144
+ bottom: mod.border_along?('bottom') ? border : nil,
145
+ range: grid_range_for_cell(cell)
146
+ )
147
+ end
148
+
149
+ def build_update_borders_request(cell)
150
+ sheets_ns::Request.new(update_borders: build_border(cell))
151
+ end
152
+
153
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
154
+ def build_batch_request(rows)
155
+ sheets_ns::BatchUpdateSpreadsheetRequest.new.tap do |bu|
156
+ bu.requests =
157
+ rows.each_slice(1000).to_a.map do |chunked_rows|
158
+ sheets_ns::Request.new(update_cells: build_update_cells_request(chunked_rows))
159
+ end
160
+
161
+ rows.each do |row|
162
+ row.cells.filter { |c| c.modifier.any_border? }
163
+ .each do |cell|
164
+ bu.requests << build_update_borders_request(cell)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
170
+ end
171
+ # rubocop:enable Metrics/ClassLength
172
+ end
173
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_writer'
4
+ require_relative 'google_sheet_builder'
5
+
6
+ AUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets'].freeze
7
+ FULL_RANGE = 'A1:Z1000'
8
+
9
+ module CSVPlusPlus
10
+ module Writer
11
+ # A class that can output a +Template+ to Google Sheets (via their API)
12
+ class GoogleSheets < ::CSVPlusPlus::Writer::BaseWriter
13
+ # XXX it would be nice to raise this but we shouldn't expand out more than necessary for our data
14
+ SPREADSHEET_INFINITY = 1000
15
+ public_constant :SPREADSHEET_INFINITY
16
+
17
+ # initialize
18
+ def initialize(options)
19
+ super(options)
20
+
21
+ @sheet_id = options.google.sheet_id
22
+ @sheet_name = options.sheet_name
23
+ end
24
+
25
+ # write a +template+ to Google Sheets
26
+ def write(template)
27
+ auth!
28
+
29
+ save_spreadsheet!
30
+ save_spreadsheet_values!
31
+
32
+ create_sheet! if @options.create_if_not_exists
33
+
34
+ update_cells!(template)
35
+ rescue ::Google::Apis::ClientError => e
36
+ handle_google_error(e)
37
+ end
38
+
39
+ protected
40
+
41
+ def load_requires
42
+ require('google/apis/sheets_v4')
43
+ require('googleauth')
44
+ end
45
+
46
+ private
47
+
48
+ def format_range(range)
49
+ @sheet_name ? "'#{@sheet_name}'!#{range}" : range
50
+ end
51
+
52
+ def full_range
53
+ format_range(::FULL_RANGE)
54
+ end
55
+
56
+ def auth!
57
+ @gs ||= sheets_ns::SheetsService.new
58
+ @gs.authorization = ::Google::Auth.get_application_default(::AUTH_SCOPES)
59
+ end
60
+
61
+ def save_spreadsheet_values!
62
+ formatted_values = get_all_spreadsheet_values('FORMATTED_VALUE')
63
+ formula_values = get_all_spreadsheet_values('FORMULA')
64
+
65
+ return if formula_values.values.nil? || formatted_values.values.nil?
66
+
67
+ @current_values = extract_current_values(formatted_values, formula_values)
68
+ end
69
+
70
+ def extract_current_values(formatted_values, formula_values)
71
+ formatted_values.values.map.each_with_index do |row, x|
72
+ row.map.each_with_index do |_cell, y|
73
+ formula_value = formula_values.values[x][y]
74
+ if formula_value.is_a?(::String) && formula_value.start_with?('=')
75
+ formula_value
76
+ else
77
+ strip_to_nil(formatted_values.values[x][y])
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ def strip_to_nil(str)
84
+ str.strip.empty? ? nil : str
85
+ end
86
+
87
+ def get_all_spreadsheet_values(render_option)
88
+ @gs.get_spreadsheet_values(@sheet_id, full_range, value_render_option: render_option)
89
+ end
90
+
91
+ def sheet
92
+ return unless @sheet_name
93
+
94
+ @spreadsheet.sheets.find { |s| s.properties.title.strip == @sheet_name.strip }
95
+ end
96
+
97
+ def save_spreadsheet!
98
+ @spreadsheet = @gs.get_spreadsheet(@sheet_id)
99
+
100
+ return unless @sheet_name.nil?
101
+
102
+ @sheet_name = @spreadsheet.sheets&.first&.properties&.title
103
+ end
104
+
105
+ def create_sheet!
106
+ return if sheet
107
+
108
+ @gs.create_spreadsheet(@sheet_name)
109
+ get_spreadsheet!
110
+ @sheet_name = @spreadsheet.sheets.last.properties.title
111
+ end
112
+
113
+ def update_cells!(template)
114
+ builder = ::CSVPlusPlus::Writer::GoogleSheetBuilder.new(
115
+ rows: template.rows,
116
+ sheet_id: sheet&.properties&.sheet_id,
117
+ column_index: @options.offset[1],
118
+ row_index: @options.offset[0],
119
+ current_sheet_values: @current_sheet_values
120
+ )
121
+ @gs.batch_update_spreadsheet(@sheet_id, builder.batch_update_spreadsheet_request)
122
+ rescue ::Google::Apis::ClientError => e
123
+ handle_google_error(e)
124
+ end
125
+
126
+ def sheets_ns
127
+ ::Google::Apis::SheetsV4
128
+ end
129
+
130
+ def handle_google_error(error)
131
+ if @options.verbose
132
+ warn("#{error.status_code} Error making Google Sheets API request [#{error.message}]: #{error.body}")
133
+ else
134
+ warn("Error making Google Sheets API request: #{error.message}")
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ ##
6
+ # A class that can output a +Template+ to an Excel file
7
+ class OpenDocument < ::CSVPlusPlus::Writer::BaseWriter
8
+ # write a +template+ to an OpenDocument file
9
+ def write(template)
10
+ # TODO
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './writer/base_writer'
4
+ require_relative './writer/csv'
5
+ require_relative './writer/excel'
6
+ require_relative './writer/google_sheets'
7
+ require_relative './writer/open_document'
8
+
9
+ module CSVPlusPlus
10
+ ##
11
+ # Various strategies for writing to various formats (excel, google sheets, CSV, OpenDocument)
12
+ module Writer
13
+ # Return an instance of a writer depending on the given +options+
14
+ def self.writer(options)
15
+ return ::CSVPlusPlus::Writer::GoogleSheets.new(options) if options.google
16
+
17
+ case options.output_filename
18
+ when /\.csv$/ then ::CSVPlusPlus::Writer::CSV.new(options)
19
+ when /\.ods$/ then ::CSVPlusPlus::Writer::OpenDocument.new(options)
20
+ when /\.xls$/ then ::CSVPlusPlus::Writer::Excel.new(options)
21
+ else raise(::StandardError, "Unsupported extension: #{options.output_filename}")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'csv_plus_plus/language/compiler'
4
+ require_relative 'csv_plus_plus/options'
5
+ require_relative 'csv_plus_plus/writer'
6
+
7
+ # A language for writing rich CSV data
8
+ module CSVPlusPlus
9
+ # Parse the input into a +Template+ and write it to the desired format
10
+ def self.apply_template_to_sheet!(input, filename, options)
11
+ warn(options.verbose_summary) if options.verbose
12
+
13
+ ::CSVPlusPlus::Language::Compiler.with_compiler(input:, filename:, options:) do |c|
14
+ template = c.parse_template
15
+
16
+ output = ::CSVPlusPlus::Writer.writer(options)
17
+ c.outputting! { output.write(template) }
18
+ end
19
+ end
20
+ end