csv_plus_plus 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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