csv_plus_plus 0.0.3 → 0.0.5

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.
@@ -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