csv_plus_plus 0.0.3 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './cli_flag'
3
4
  require_relative './google_options'
4
5
 
5
6
  module CSVPlusPlus
@@ -14,7 +15,6 @@ module CSVPlusPlus
14
15
  @create_if_not_exists = false
15
16
  @key_values = {}
16
17
  @verbose = false
17
- # TODO: switch to true? probably a safer choice
18
18
  @backup = false
19
19
  end
20
20
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CSVPlusPlus
4
- VERSION = '0.0.3'
4
+ VERSION = '0.0.5'
5
5
  public_constant :VERSION
6
6
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module CSVPlusPlus
4
4
  module Writer
5
- ##
6
5
  # Some shared functionality that all Writers should build on
7
6
  class BaseWriter
8
7
  attr_accessor :options
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './file_backer_upper'
4
+
3
5
  module CSVPlusPlus
4
6
  module Writer
5
- ##
6
7
  # A class that can output a +Template+ to CSV
7
8
  class CSV < ::CSVPlusPlus::Writer::BaseWriter
9
+ include ::CSVPlusPlus::Writer::FileBackerUpper
10
+
8
11
  # write a +template+ to CSV
9
12
  def write(template)
10
13
  # TODO: also read it and merge the results
@@ -1,12 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './file_backer_upper'
4
+ require_relative './rubyxl_builder'
5
+
3
6
  module CSVPlusPlus
4
7
  module Writer
5
8
  # A class that can output a +Template+ to an Excel file
6
9
  class Excel < ::CSVPlusPlus::Writer::BaseWriter
7
- # write a +template+ to an Excel file
10
+ include ::CSVPlusPlus::Writer::FileBackerUpper
11
+
12
+ # write the +template+ to an Excel file
8
13
  def write(template)
9
- # TODO
14
+ ::CSVPlusPlus::Writer::RubyXLBuilder.new(
15
+ output_filename: @options.output_filename,
16
+ rows: template.rows,
17
+ sheet_name: @options.sheet_name
18
+ ).write
19
+ end
20
+
21
+ protected
22
+
23
+ def load_requires
24
+ require('rubyXL')
25
+ require('rubyXL/convenience_methods')
10
26
  end
11
27
  end
12
28
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+
6
+ module CSVPlusPlus
7
+ module Writer
8
+ # A mixin that can
9
+ module FileBackerUpper
10
+ # we don't want to include a bunch of second/millisecond stuff in the filename unless we
11
+ # really need to. so try a less specifically formatted filename then get more specific
12
+ DESIRED_BACKUP_FORMATS = [%(%Y_%m_%d-%I_%M%p), %(%Y_%m_%d-%I_%M_%S%p), %(%Y_%m_%d-%I_%M_%S_%L%p)].freeze
13
+ private_constant :DESIRED_BACKUP_FORMATS
14
+
15
+ # Assuming the underlying spreadsheet is file-based, create a backup of it
16
+ # rubocop:disable Metrics/MethodLength
17
+ def write_backup
18
+ return unless ::File.exist?(@options.output_filename)
19
+
20
+ attempted = []
21
+ backed_up_to = nil
22
+
23
+ # rubocop:disable Lint/ConstantResolution
24
+ DESIRED_BACKUP_FORMATS.find do |file_format|
25
+ # rubocop:enable Lint/ConstantResolution
26
+ filename = format_backup_filename(file_format)
27
+ attempted << filename
28
+ backed_up_to = backup(filename)
29
+
30
+ break if backed_up_to
31
+ end
32
+
33
+ unless backed_up_to
34
+ raise(::CSVPlusPlus::Error, "Unable to write backup file despite trying these: #{attempted.join(', ')}")
35
+ end
36
+
37
+ warn("Backed up #{@options.output_filename} to #{backed_up_to}") if @options.verbose
38
+ end
39
+ # rubocop:enable Metrics/MethodLength
40
+
41
+ private
42
+
43
+ def backup(filename)
44
+ return if ::File.exist?(filename)
45
+
46
+ ::FileUtils.cp(@options.output_filename, filename)
47
+ filename
48
+ end
49
+
50
+ def format_backup_filename(file_format)
51
+ pn = ::Pathname.new(@options.output_filename)
52
+ pn.sub_ext("-#{::Time.now.strftime(file_format)}" + pn.extname)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative './google_sheet_modifier'
4
+
3
5
  module CSVPlusPlus
4
6
  module Writer
5
- ##
6
7
  # Given +rows+ from a +Template+, build requests compatible with Google Sheets Ruby API
7
8
  # rubocop:disable Metrics/ClassLength
8
9
  class GoogleSheetBuilder
@@ -26,9 +27,7 @@ module CSVPlusPlus
26
27
  ::Google::Apis::SheetsV4
27
28
  end
28
29
 
29
- def sheets_color(color)
30
- sheets_ns::Color.new(red: color.red, green: color.green, blue: color.blue)
31
- end
30
+ def sheets_color(color); end
32
31
 
33
32
  def set_extended_value_type!(extended_value, value)
34
33
  v = value || ''
@@ -43,38 +42,20 @@ module CSVPlusPlus
43
42
  end
44
43
  end
45
44
 
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
45
+ # rubocop:disable Metrics/AbcSize
61
46
  def build_cell_format(mod)
62
47
  sheets_ns::CellFormat.new.tap do |cf|
63
- cf.text_format = build_text_format(mod)
48
+ cf.text_format = mod.text_format
64
49
 
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')
50
+ cf.horizontal_alignment = mod.halign if mod.halign
51
+ cf.vertical_alignment = mod.valign if mod.valign
71
52
 
72
- cf.background_color = sheets_color(mod.color) if mod.color
53
+ cf.background_color = mod.color if mod.color
73
54
 
74
- cf.number_format = sheets_ns::NumberFormat.new(type: mod.numberformat) if mod.numberformat
55
+ cf.number_format = mod.numberformat if mod.numberformat
75
56
  end
76
57
  end
77
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
58
+ # rubocop:enable Metrics/AbcSize
78
59
 
79
60
  def grid_range_for_cell(cell)
80
61
  sheets_ns::GridRange.new(
@@ -106,10 +87,10 @@ module CSVPlusPlus
106
87
  end
107
88
 
108
89
  def build_cell_data(cell)
109
- mod = cell.modifier
90
+ mod = ::CSVPlusPlus::Writer::GoogleSheetModifier.new(cell.modifier)
110
91
 
111
92
  sheets_ns::CellData.new.tap do |cd|
112
- cd.user_entered_format = build_cell_format(cell.modifier)
93
+ cd.user_entered_format = build_cell_format(mod)
113
94
  cd.note = mod.note if mod.note
114
95
 
115
96
  # XXX apply data validation
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ module Writer
5
+ # Decorate a Modifier so it can be written to the Google Sheets API
6
+ class GoogleSheetModifier < ::SimpleDelegator
7
+ # Format the halign for Google Sheets
8
+ def halign
9
+ super&.upcase
10
+ end
11
+
12
+ # Format the valign for Google Sheets
13
+ def valign
14
+ super&.upcase
15
+ end
16
+
17
+ # Format the color for Google Sheets
18
+ def color
19
+ google_sheets_color(super) if super
20
+ end
21
+
22
+ # Format the fontcolor for Google Sheets
23
+ def fontcolor
24
+ google_sheets_color(super) if super
25
+ end
26
+
27
+ # Format the numberformat for Google Sheets
28
+ def numberformat
29
+ ::Google::Apis::SheetsV4::NumberFormat.new(type: super) if super
30
+ end
31
+
32
+ # Builds a SheetsV4::TextFormat with the underlying Modifier
33
+ def text_format
34
+ ::Google::Apis::SheetsV4::TextFormat.new(
35
+ bold: formatted?('bold') || nil,
36
+ italic: formatted?('italic') || nil,
37
+ strikethrough: formatted?('strikethrough') || nil,
38
+ underline: formatted?('underline') || nil,
39
+ font_family: fontfamily,
40
+ font_size: fontsize,
41
+ foreground_color: fontcolor
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def google_sheets_color(color)
48
+ ::Google::Apis::SheetsV4::Color.new(
49
+ red: color.red_percent,
50
+ green: color.green_percent,
51
+ blue: color.blue_percent
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,16 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../google_api_client'
3
4
  require_relative 'base_writer'
4
5
  require_relative 'google_sheet_builder'
5
6
 
6
- AUTH_SCOPES = ['https://www.googleapis.com/auth/spreadsheets'].freeze
7
- FULL_RANGE = 'A1:Z1000'
8
-
9
7
  module CSVPlusPlus
10
8
  module Writer
11
- # A class that can output a +Template+ to Google Sheets (via their API)
9
+ # A class that can write a +Template+ to Google Sheets (via their API)
12
10
  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
11
+ # TODO: it would be nice to raise this but we shouldn't expand out more than necessary for our data
14
12
  SPREADSHEET_INFINITY = 1000
15
13
  public_constant :SPREADSHEET_INFINITY
16
14
 
@@ -24,21 +22,26 @@ module CSVPlusPlus
24
22
 
25
23
  # write a +template+ to Google Sheets
26
24
  def write(template)
27
- auth!
25
+ @sheets_client = ::CSVPlusPlus::GoogleApiClient.sheets_client
28
26
 
29
- save_spreadsheet!
30
- save_spreadsheet_values!
27
+ fetch_spreadsheet!
28
+ fetch_spreadsheet_values!
31
29
 
32
30
  create_sheet! if @options.create_if_not_exists
33
31
 
34
32
  update_cells!(template)
35
- rescue ::Google::Apis::ClientError => e
36
- handle_google_error(e)
33
+ end
34
+
35
+ # write a backup of the google sheet
36
+ def write_backup
37
+ drive_client = ::CSVPlusPlus::GoogleApiClient.drive_client
38
+ drive_client.copy_file(@sheet_id)
37
39
  end
38
40
 
39
41
  protected
40
42
 
41
43
  def load_requires
44
+ require('google/apis/drive_v3')
42
45
  require('google/apis/sheets_v4')
43
46
  require('googleauth')
44
47
  end
@@ -50,15 +53,10 @@ module CSVPlusPlus
50
53
  end
51
54
 
52
55
  def full_range
53
- format_range(::FULL_RANGE)
56
+ format_range('A1:Z1000')
54
57
  end
55
58
 
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!
59
+ def fetch_spreadsheet_values!
62
60
  formatted_values = get_all_spreadsheet_values('FORMATTED_VALUE')
63
61
  formula_values = get_all_spreadsheet_values('FORMULA')
64
62
 
@@ -85,7 +83,7 @@ module CSVPlusPlus
85
83
  end
86
84
 
87
85
  def get_all_spreadsheet_values(render_option)
88
- @gs.get_spreadsheet_values(@sheet_id, full_range, value_render_option: render_option)
86
+ @sheets_client.get_spreadsheet_values(@sheet_id, full_range, value_render_option: render_option)
89
87
  end
90
88
 
91
89
  def sheet
@@ -94,8 +92,8 @@ module CSVPlusPlus
94
92
  @spreadsheet.sheets.find { |s| s.properties.title.strip == @sheet_name.strip }
95
93
  end
96
94
 
97
- def save_spreadsheet!
98
- @spreadsheet = @gs.get_spreadsheet(@sheet_id)
95
+ def fetch_spreadsheet!
96
+ @spreadsheet = @sheets_client.get_spreadsheet(@sheet_id)
99
97
 
100
98
  return unless @sheet_name.nil?
101
99
 
@@ -105,34 +103,23 @@ module CSVPlusPlus
105
103
  def create_sheet!
106
104
  return if sheet
107
105
 
108
- @gs.create_spreadsheet(@sheet_name)
109
- get_spreadsheet!
106
+ @sheets_client.create_spreadsheet(@sheet_name)
107
+ fetch_spreadsheet!
110
108
  @sheet_name = @spreadsheet.sheets.last.properties.title
111
109
  end
112
110
 
113
111
  def update_cells!(template)
114
- builder = ::CSVPlusPlus::Writer::GoogleSheetBuilder.new(
112
+ @sheets_client.batch_update_spreadsheet(@sheet_id, builder(template).batch_update_spreadsheet_request)
113
+ end
114
+
115
+ def builder(template)
116
+ ::CSVPlusPlus::Writer::GoogleSheetBuilder.new(
115
117
  rows: template.rows,
116
118
  sheet_id: sheet&.properties&.sheet_id,
117
119
  column_index: @options.offset[1],
118
120
  row_index: @options.offset[0],
119
121
  current_sheet_values: @current_sheet_values
120
122
  )
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
123
  end
137
124
  end
138
125
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './rubyxl_modifier'
4
+
5
+ module CSVPlusPlus
6
+ module Writer
7
+ # Build a RubyXL workbook formatted according to the given +rows+
8
+ class RubyXLBuilder
9
+ attr_reader :output_filename, :rows
10
+
11
+ # initialize
12
+ def initialize(output_filename:, rows:, sheet_name:)
13
+ @rows = rows
14
+ @output_filename = output_filename
15
+ @workbook = open_workbook(sheet_name)
16
+ @worksheet = @workbook[sheet_name]
17
+ end
18
+
19
+ # write the given @rows in +sheet_name+ to +@output_filename+
20
+ def write
21
+ build_workbook!
22
+ @workbook.write(@output_filename)
23
+ end
24
+
25
+ private
26
+
27
+ def build_workbook!
28
+ @rows.each_with_index do |row, x|
29
+ row.cells.each_with_index do |cell, y|
30
+ modifier = ::CSVPlusPlus::Writer::RubyXLModifier.new(cell.modifier)
31
+
32
+ @worksheet.add_cell(x, y, cell.to_csv)
33
+ format_cell!(x, y, modifier)
34
+ end
35
+ end
36
+ end
37
+
38
+ def do_alignments!(cell, modifier)
39
+ cell.change_horizontal_alignment(modifier.halign) if modifier.halign
40
+ cell.change_vertical_alignment(modifier.valign) if modifier.valign
41
+ end
42
+
43
+ # rubocop:disable Metrics/MethodLength
44
+ def do_borders!(cell, modifier)
45
+ return unless modifier.any_border?
46
+
47
+ color = modifier.bordercolor
48
+ weight = modifier.border_weight
49
+
50
+ if modifier.border_all?
51
+ %i[top bottom left right].each do |direction|
52
+ # TODO: I can't support a weight and a color?
53
+ cell.change_border(direction, color || weight)
54
+ end
55
+ else
56
+ modifier.borders.each do |direction|
57
+ cell.change_border(direction, color || weight)
58
+ end
59
+ end
60
+ end
61
+ # rubocop:enable Metrics/MethodLength
62
+
63
+ def do_fill!(cell, modifier)
64
+ cell.change_fill(modifier.color.to_hex) if modifier.color
65
+ end
66
+
67
+ def do_formats!(cell, modifier)
68
+ cell.change_font_bold(true) if modifier.formatted?('bold')
69
+ cell.change_font_italics(true) if modifier.formatted?('italic')
70
+ cell.change_font_underline(true) if modifier.formatted?('underline')
71
+ cell.change_font_strikethrough(true) if modifier.formatted?('strikethrough')
72
+ end
73
+
74
+ def do_fonts!(cell, modifier)
75
+ cell.change_font_color(modifier.fontcolor.to_hex) if modifier.fontcolor
76
+ cell.change_font_name(modifier.fontfamily) if modifier.fontfamily
77
+ cell.change_font_size(modifier.fontsize) if modifier.fontsize
78
+ end
79
+
80
+ def do_number_formats!(cell, modifier)
81
+ return unless modifier.numberformat
82
+
83
+ cell.set_number_format(modifier.number_format_code)
84
+ # TODO: this is annoying... we have to set the contents with the correct type of object
85
+ cell.change_contents(cell.value)
86
+ end
87
+
88
+ def format_cell!(row_index, cell_index, modifier)
89
+ @worksheet.sheet_data[row_index][cell_index].tap do |cell|
90
+ do_alignments!(cell, modifier)
91
+ do_borders!(cell, modifier)
92
+ do_fill!(cell, modifier)
93
+ do_fonts!(cell, modifier)
94
+ do_formats!(cell, modifier)
95
+ do_number_formats!(cell, modifier)
96
+ end
97
+ end
98
+
99
+ def open_workbook(sheet_name)
100
+ if ::File.exist?(@output_filename)
101
+ ::RubyXL::Parser.parse(@output_filename).tap do |workbook|
102
+ workbook.add_worksheet(sheet_name) unless workbook[sheet_name]
103
+ end
104
+ else
105
+ ::RubyXL::Workbook.new.tap do |workbook|
106
+ workbook.worksheets[0].sheet_name = sheet_name
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CSVPlusPlus
4
+ # Writer
5
+ module Writer
6
+ # Build a RubyXL-decorated Modifier class adds some support for Excel
7
+ class RubyXLModifier < ::SimpleDelegator
8
+ # https://www.rubydoc.info/gems/rubyXL/RubyXL/NumberFormats
9
+ # https://support.microsoft.com/en-us/office/number-format-codes-5026bbd6-04bc-48cd-bf33-80f18b4eae68
10
+ NUM_FMT_IDS = {
11
+ currency: 5,
12
+ date: 14,
13
+ date_time: 22,
14
+ number: 1,
15
+ percent: 9,
16
+ text: 49,
17
+ time: 21,
18
+ scientific: 48
19
+ }.freeze
20
+ private_constant :NUM_FMT_IDS
21
+
22
+ # https://www.rubydoc.info/gems/rubyXL/2.3.0/RubyXL
23
+ # ST_BorderStyle = %w{ none thin medium dashed dotted thick double hair mediumDashed dashDot mediumDashDot
24
+ # dashDotDot slantDashDot }
25
+ BORDER_STYLES = {
26
+ dashed: 'dashed',
27
+ dotted: 'dotted',
28
+ double: 'double',
29
+ solid: 'thin',
30
+ solid_medium: 'medium',
31
+ solid_thick: 'thick'
32
+ }.freeze
33
+ private_constant :BORDER_STYLES
34
+
35
+ # The excel-specific number format code
36
+ def number_format_code
37
+ ::RubyXL::NumberFormats::DEFAULT_NUMBER_FORMATS.find_by_format_id(
38
+ # rubocop:disable Lint/ConstantResolution
39
+ NUM_FMT_IDS[numberformat.to_sym]
40
+ # rubocop:enable Lint/ConstantResolution
41
+ ).format_code
42
+ end
43
+
44
+ # The excel-specific border weight
45
+ def border_weight
46
+ # rubocop:disable Lint/ConstantResolution
47
+ BORDER_STYLES[borderstyle.to_sym]
48
+ # rubocop:enable Lint/ConstantResolution
49
+ end
50
+ end
51
+ end
52
+ end
@@ -7,7 +7,6 @@ require_relative './writer/google_sheets'
7
7
  require_relative './writer/open_document'
8
8
 
9
9
  module CSVPlusPlus
10
- ##
11
10
  # Various strategies for writing to various formats (excel, google sheets, CSV, OpenDocument)
12
11
  module Writer
13
12
  # Return an instance of a writer depending on the given +options+
@@ -17,8 +16,8 @@ module CSVPlusPlus
17
16
  case options.output_filename
18
17
  when /\.csv$/ then ::CSVPlusPlus::Writer::CSV.new(options)
19
18
  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}")
19
+ when /\.xl(sx|sm|tx|tm)$/ then ::CSVPlusPlus::Writer::Excel.new(options)
20
+ else raise(::CSVPlusPlus::Error, "Unsupported file extension: #{options.output_filename}")
22
21
  end
23
22
  end
24
23
  end
data/lib/csv_plus_plus.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'csv_plus_plus/cli'
4
+ require_relative 'csv_plus_plus/error'
3
5
  require_relative 'csv_plus_plus/language/compiler'
4
6
  require_relative 'csv_plus_plus/options'
5
7
  require_relative 'csv_plus_plus/writer'
@@ -14,7 +16,10 @@ module CSVPlusPlus
14
16
  template = c.parse_template
15
17
 
16
18
  output = ::CSVPlusPlus::Writer.writer(options)
17
- c.outputting! { output.write(template) }
19
+ c.outputting! do
20
+ output.write_backup if options.backup
21
+ output.write(template)
22
+ end
18
23
  end
19
24
  end
20
25
  end