From 86c96d3f5c9a4f8dc6ac173e17547a2ce82bf975 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Fri, 5 Aug 2022 22:59:40 -0700 Subject: [PATCH 01/26] Switch to require_relative --- lib/spreadsheet_exporter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spreadsheet_exporter.rb b/lib/spreadsheet_exporter.rb index b07bded..e2df902 100644 --- a/lib/spreadsheet_exporter.rb +++ b/lib/spreadsheet_exporter.rb @@ -1,5 +1,5 @@ -require 'spreadsheet_exporter/csv' -require 'spreadsheet_exporter/xlsx' +require_relative './spreadsheet_exporter/csv' +require_relative './spreadsheet_exporter/xlsx' module SpreadsheetExporter begin Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx From c0fb717f3da7bd91a37bde97631550d2ef044e04 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Fri, 5 Aug 2022 23:01:12 -0700 Subject: [PATCH 02/26] Use IOString instead of a tempfile when generating XLSX --- lib/spreadsheet_exporter/xlsx.rb | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index ce2378b..2065cd8 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -8,9 +8,10 @@ def self.from_objects(objects, options = {}) from_spreadsheet(spreadsheet) end - def self.from_spreadsheet(spreadsheet, temp_file_path = 'tmp/items.xlsx') + def self.from_spreadsheet(spreadsheet) + io = StringIO.new # Create a new Excel workbook - workbook = WriteXLSX.new(temp_file_path) + workbook = WriteXLSX.new(io) # Add a worksheet worksheet = workbook.add_worksheet @@ -30,14 +31,9 @@ def self.from_spreadsheet(spreadsheet, temp_file_path = 'tmp/items.xlsx') end end - # Output the file contents and delete it workbook.close - file = File.open(temp_file_path) - output = file.read - file.close - File.delete(temp_file_path) + io.string - return output end end end From 001a54fd079e954501791831d178c96a7306f0b8 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Fri, 5 Aug 2022 23:24:22 -0700 Subject: [PATCH 03/26] Add as_json from ActiveSupport --- Gemfile.lock | 20 ++++++++++++++++---- lib/spreadsheet_exporter.rb | 3 +++ spreadsheet_exporter.gemspec | 1 + 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a9d8372..b40b321 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,26 @@ PATH remote: . specs: - spreadsheet_exporter (0.1.1) + spreadsheet_exporter (0.1.2) + activesupport (>= 6) write_xlsx GEM remote: https://rubygems.org/ specs: - rubyzip (2.3.0) - write_xlsx (0.83.0) + activesupport (7.0.3.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + concurrent-ruby (1.1.10) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + minitest (5.16.2) + rubyzip (2.3.2) + tzinfo (2.0.5) + concurrent-ruby (~> 1.0) + write_xlsx (1.09.3) rubyzip (>= 1.0.0) zip-zip zip-zip (0.3) @@ -21,4 +33,4 @@ DEPENDENCIES spreadsheet_exporter! BUNDLED WITH - 1.10.4 + 2.3.19 diff --git a/lib/spreadsheet_exporter.rb b/lib/spreadsheet_exporter.rb index e2df902..c27e3b2 100644 --- a/lib/spreadsheet_exporter.rb +++ b/lib/spreadsheet_exporter.rb @@ -1,5 +1,8 @@ require_relative './spreadsheet_exporter/csv' require_relative './spreadsheet_exporter/xlsx' +require 'active_support' +require 'active_support/core_ext/object/json' + module SpreadsheetExporter begin Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx diff --git a/spreadsheet_exporter.gemspec b/spreadsheet_exporter.gemspec index a216056..0a1402a 100644 --- a/spreadsheet_exporter.gemspec +++ b/spreadsheet_exporter.gemspec @@ -17,5 +17,6 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] s.test_files = Dir["test/**/*"] + s.add_dependency "activesupport", ">= 6" s.add_dependency "write_xlsx" end From c87fc877f7ab8cbd01ddf75b753c557c9440ec78 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Fri, 5 Aug 2022 23:24:50 -0700 Subject: [PATCH 04/26] Ignore *.xlsx --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b268508..593e331 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test/dummy/log/*.log test/dummy/tmp/ test/dummy/.sass-cache *.gem +*.xlsx From b790bc526551e2a12a1655da0e8132ef165c986f Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Fri, 5 Aug 2022 23:25:15 -0700 Subject: [PATCH 05/26] Spike of data validation logic --- lib/spreadsheet_exporter/xlsx.rb | 101 +++++++++++++++++++++++++++---- test.rb | 43 +++++++++++++ test_data.rb | 53 ++++++++++++++++ 3 files changed, 185 insertions(+), 12 deletions(-) create mode 100755 test.rb create mode 100644 test_data.rb diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 2065cd8..fb6c87f 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -3,37 +3,114 @@ module SpreadsheetExporter module XLSX + extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` + + ROW_MAX = 65_536 - 1 + USE_INLINE_LISTS = true # debug toggle, not for production + MAX_INLINE_LIST_CHARS = 255 + + VALIDATION_ERROR_TYPES = %w[stop warning information].freeze + DATA_WORKSHEET_NAME = "data".freeze + def self.from_objects(objects, options = {}) spreadsheet = Spreadsheet.from_objects(objects, options).compact - from_spreadsheet(spreadsheet) + from_spreadsheet(spreadsheet, options) end - def self.from_spreadsheet(spreadsheet) + def self.from_spreadsheet(spreadsheet, options = {}) io = StringIO.new - # Create a new Excel workbook workbook = WriteXLSX.new(io) - # Add a worksheet worksheet = workbook.add_worksheet - # Add and define a format - headerFormat = workbook.add_format # Add a format - headerFormat.set_bold + header_format = workbook.add_format + header_format.set_bold + + column_indexes = {} # Write header row Array(spreadsheet.first).each_with_index do |column_name, col| - worksheet.write(0, col, column_name, headerFormat) + worksheet.write(0, col, column_name, header_format) + column_indexes[column_name] = col end - Array(spreadsheet[1..-1]).each_with_index do |values, row| - Array(values).each_with_index do |value, col| - worksheet.write(row + 1, col, value) - end + Array(spreadsheet[1..]).each_with_index do |values, row| + worksheet.write_row(row + 1, 0, Array(values)) + end + + add_worksheet_validation(workbook, worksheet, column_indexes, header_format, options) + + workbook.worksheets.each do |ws| + ws.freeze_panes(1, 0) end workbook.close io.string + end + + def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_format, options = {}) + column_validations = options.fetch("validations", {}) + return if column_validations.empty? + + column_validations.each do |column_name, column_validation| + column_index = column_indexes[column_name] + + if column_index.nil? + # TODO: we should output an empty column anyways + warn "attempted to apply validation to missing column '#{column_name}'" + next + end + + validation_options = add_column_validation(workbook, column_name, column_index, column_validation, header_format) + + pp validation_options + + worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) + end + end + + def self.add_column_validation(workbook, column_name, column_index, column_validation, header_format) + list_values = column_validation.fetch("source", []) + if list_values.empty? + raise ArgumentError, "no values for validation for column '#{column_name}'" + end + + error_type = column_validation.fetch("error_type", VALIDATION_ERROR_TYPES[0]) + unless VALIDATION_ERROR_TYPES.include?(error_type) + raise ArgumentError, "invalid error_type `#{error_type}` for validation for column '#{column_name}'" + end + + list_length = list_values.join(",").length + + source = nil + + if USE_INLINE_LISTS && list_length <= MAX_INLINE_LIST_CHARS + source = list_values + else + data_start = xl_rowcol_to_cell(1, column_index) + data_end = xl_rowcol_to_cell(ROW_MAX, column_index) + + source = "=data!$#{data_start}:#{data_end}" + warn "list values for column #{column_name} too long to be inlined, " \ + "len #{list_length} > #{MAX_INLINE_LIST_CHARS}, moving source to #{source}" + + unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) + data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) + end + + data_sheet.write(0, column_index, column_name, header_format) + data_sheet.write_col(1, column_index, list_values) + end + { + "validate" => "list", + "input_title" => "Select a value", + "source" => source, + "error_message" => column_validation.fetch("error_message", "Please select a valid option"), + "error_type" => error_type, + "ignore_blank" => column_validation.fetch("ignore_blank", true), + "dropdown" => true + } end end end diff --git a/test.rb b/test.rb new file mode 100755 index 0000000..b2dedc2 --- /dev/null +++ b/test.rb @@ -0,0 +1,43 @@ +#!/usr/bin/env ruby +require_relative "./lib/spreadsheet_exporter" +require_relative "./test_data" +require "awesome_print" +require "debug" + +# http://support.microsoft.com/kb/211485 +# +# TODO: we should add a column for all the missing validations even if there is +# no data in it yet + +data = [ + {"name" => "Jim", "role" => "admin", "city" => "Vancouver"}, + {"name" => "Sally", "role" => "user"}, + {"name" => "Horatio", "role" => "user", "meal" => "Paleo"}, + {"name" => "Jan", "role" => "user", "site_type" => SITE_TYPES.sample} +] + +options = { + "validations" => { + "role" => { + "ignore_blank" => false, + "source" => %w[admin user spammer boss] + }, + "city" => { + "ignore_blank" => true, + "error_type" => "information", + "source" => %w[Victoria Vancouver Courtenay] + }, + "meal" => { + "ignore_blank" => true, + "error_type" => "warning", + "source" => %w[Omnivore Veg Vegan] + }, + "site_type" => { + "source" => SITE_TYPES + } + } +} + +File.open("output.xlsx", "wb") do |f| + f.write SpreadsheetExporter::XLSX.from_objects(data, options) +end diff --git a/test_data.rb b/test_data.rb new file mode 100644 index 0000000..9e1c8f6 --- /dev/null +++ b/test_data.rb @@ -0,0 +1,53 @@ +SITE_TYPES = [ + "Other Practices: Canoe Races", + "Other Practices: Canoe training", + "Other Practices: Healing Place", + "Sxwōxwiyám: Other", + "Sxwōxwiyám: Sqáyéx/Xwyélés", + "Sxwōxwiyám: Tel Sweyal", + "Sxwōxwiyám: Wōqw'", + "Sxwōxwiyám: X̠éyt", + "Xá:Xa: Burial", + "Xá:Xa: Cave", + "Xá:Xa: Mimestíyexw Place", + "Xá:Xa: Regalia Placement Place", + "Xá:Xa: Shxwexwó:s Cave", + "Xá:Xa: Stl’áleqem Place", + "Xá:Xa: Sxwó:yxwey Origin Place", + "Xá:Xa: S’ó:lmexw Place", + "Xá:Xa: Tunnel", + "Metaphysical Being: Pítxel", + "Metaphysical Being: Shxwexwos", + "Metaphysical Being: Spirit Animal", + "Metaphysical Being: Sásq’ets", + "Spiritual Practice: Power Questing", + "Spiritual Practice: Afterbirth Place", + "Spiritual Practice: Burning Place", + "Spiritual Practice: Ceremonial Feeding", + "Spiritual Practice: Healing Rock", + "Spiritual Practice: Isolation/Puberty Place", + "Spiritual Practice: Prayer Tree", + "Spiritual Practice: Smílha Place", + "Spiritual Practice: Sweats Place", + "Spiritual Practice: Sxwó:yxwey Place", + "Material Culture: Archaeological Site", + "Material Culture: Archaeological Site Lead", + "Material Culture: CMT", + "Material Culture: Surficial Find", + "Material Culture: Symbol", + "Navigation: Geographic Location", + "Resource Management: Burning Place", + "Resource Management: Clam Bed", + "Resource Management: Deer Drive", + "Resource Management: Fishingweir/Trap", + "Resource Management: Irrigation/Spring/Waterway", + "Resource Management: Plantation/Farm", + "Resource Management: Spawning Ground", + "Resource Harvesting: Aquatic Harvesting", + "Resource Harvesting: Aquatic Processing", + "Resource Harvesting: Habitation", + "Resource Harvesting: Hunting", + "Resource Harvesting: Terrestrial Harvesting", + "Resource Harvesting: Trapping", + "Resource Harvesting: Travel" +].map { |s| s.encode("UTF-8")} From 241aa442aa3329ef8a5e939368e339204c35540b Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Sat, 6 Aug 2022 14:30:54 -0700 Subject: [PATCH 06/26] Improve after integrating in the app --- lib/spreadsheet_exporter/xlsx.rb | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index fb6c87f..f4a84d6 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -6,7 +6,7 @@ module XLSX extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` ROW_MAX = 65_536 - 1 - USE_INLINE_LISTS = true # debug toggle, not for production + USE_INLINE_LISTS = false # debug toggle, not for production MAX_INLINE_LIST_CHARS = 255 VALIDATION_ERROR_TYPES = %w[stop warning information].freeze @@ -14,7 +14,7 @@ module XLSX def self.from_objects(objects, options = {}) spreadsheet = Spreadsheet.from_objects(objects, options).compact - from_spreadsheet(spreadsheet, options) + from_spreadsheet(spreadsheet, options.deep_stringify_keys) end def self.from_spreadsheet(spreadsheet, options = {}) @@ -48,10 +48,24 @@ def self.from_spreadsheet(spreadsheet, options = {}) io.string end + # TODO: we should DRY this up with the Spreadsheet.from_objects logic + def self.rewrite_validation_column_names(column_validations, options) + return column_validations unless options["humanize_headers_class"] + klass = options["humanize_headers_class"] + + column_validations.each_with_object({}) do |(attribute, v), obj| + rewritten = klass.human_attribute_name(attribute) + rewritten.downcase! if options[:downcase] + obj[rewritten] = v + end + end + def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_format, options = {}) - column_validations = options.fetch("validations", {}) + column_validations = options.fetch("validations", {}) || {} return if column_validations.empty? + column_validations = rewrite_validation_column_names(column_validations, options) + column_validations.each do |column_name, column_validation| column_index = column_indexes[column_name] @@ -70,7 +84,7 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_fo end def self.add_column_validation(workbook, column_name, column_index, column_validation, header_format) - list_values = column_validation.fetch("source", []) + list_values = Array(column_validation.fetch("source", [])) if list_values.empty? raise ArgumentError, "no values for validation for column '#{column_name}'" end @@ -80,11 +94,15 @@ def self.add_column_validation(workbook, column_name, column_index, column_valid raise ArgumentError, "invalid error_type `#{error_type}` for validation for column '#{column_name}'" end + list_values.compact! list_length = list_values.join(",").length source = nil if USE_INLINE_LISTS && list_length <= MAX_INLINE_LIST_CHARS + # commas are not allowed when + # TODO: we should warn about losing any commas + list_values.map! { |v| v.sub(',', '').strip } source = list_values else data_start = xl_rowcol_to_cell(1, column_index) From 8524059d92e783bad979e0bd1cbaad90f9362022 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 9 Aug 2022 12:45:43 -0700 Subject: [PATCH 07/26] Add docs --- lib/spreadsheet_exporter/xlsx.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index f4a84d6..13644b5 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -6,6 +6,12 @@ module XLSX extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` ROW_MAX = 65_536 - 1 + + # Excel allows defining validation `sources` in two different ways, an inline + # list or a reference to cells elsewhere in the workbook. + # + # The inline list is defined as a comma-separated string with a max length of + # 255 characters. USE_INLINE_LISTS = false # debug toggle, not for production MAX_INLINE_LIST_CHARS = 255 From 1850288102e6a7001bb86e2b8e363ff9a522ede3 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 9 Aug 2022 13:08:20 -0700 Subject: [PATCH 08/26] Re-organize for clarity --- lib/spreadsheet_exporter/xlsx.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 13644b5..252f6a8 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -111,17 +111,14 @@ def self.add_column_validation(workbook, column_name, column_index, column_valid list_values.map! { |v| v.sub(',', '').strip } source = list_values else - data_start = xl_rowcol_to_cell(1, column_index) - data_end = xl_rowcol_to_cell(ROW_MAX, column_index) - - source = "=data!$#{data_start}:#{data_end}" - warn "list values for column #{column_name} too long to be inlined, " \ - "len #{list_length} > #{MAX_INLINE_LIST_CHARS}, moving source to #{source}" - unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) end + data_start = xl_rowcol_to_cell(1, column_index) + data_end = xl_rowcol_to_cell(ROW_MAX, column_index) + source = "=data!$#{data_start}:#{data_end}" + data_sheet.write(0, column_index, column_name, header_format) data_sheet.write_col(1, column_index, list_values) end From f75aa1237a82cdea5b4ca574f87fe6efea58d9fb Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 9 Aug 2022 13:14:24 -0700 Subject: [PATCH 09/26] Add activesupport import --- lib/spreadsheet_exporter/xlsx.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 252f6a8..c279518 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -1,5 +1,7 @@ require 'write_xlsx' require_relative 'spreadsheet' +require "active_support" +require "active_support/core_ext/hash/keys" module SpreadsheetExporter module XLSX From 0a07e359e3e4f79e1562bc21077204856c7db32d Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 9 Aug 2022 13:14:57 -0700 Subject: [PATCH 10/26] Always use absolute references for data source lists --- lib/spreadsheet_exporter/xlsx.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index c279518..a9a0beb 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -117,9 +117,9 @@ def self.add_column_validation(workbook, column_name, column_index, column_valid data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) end - data_start = xl_rowcol_to_cell(1, column_index) - data_end = xl_rowcol_to_cell(ROW_MAX, column_index) - source = "=data!$#{data_start}:#{data_end}" + data_start = xl_rowcol_to_cell(1, column_index, true, true) + data_end = xl_rowcol_to_cell(list_values.length, column_index, true, true) + source = "=data!#{data_start}:#{data_end}" data_sheet.write(0, column_index, column_name, header_format) data_sheet.write_col(1, column_index, list_values) From 00d7bc89befbff775b5e6165b48f1e6b89e5d96c Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 9 Aug 2022 13:22:19 -0700 Subject: [PATCH 11/26] Update test suite --- test.rb | 13 +++------ test_data.rb | 78 ++++++++++++++++++---------------------------------- 2 files changed, 30 insertions(+), 61 deletions(-) diff --git a/test.rb b/test.rb index b2dedc2..11e7923 100755 --- a/test.rb +++ b/test.rb @@ -10,10 +10,10 @@ # no data in it yet data = [ - {"name" => "Jim", "role" => "admin", "city" => "Vancouver"}, + {"name" => "Jim", "role" => "admin", "city" => CITIES.sample}, {"name" => "Sally", "role" => "user"}, {"name" => "Horatio", "role" => "user", "meal" => "Paleo"}, - {"name" => "Jan", "role" => "user", "site_type" => SITE_TYPES.sample} + {"name" => "Jan", "role" => "user"} ] options = { @@ -25,19 +25,14 @@ "city" => { "ignore_blank" => true, "error_type" => "information", - "source" => %w[Victoria Vancouver Courtenay] + "source" => CITIES }, "meal" => { "ignore_blank" => true, "error_type" => "warning", "source" => %w[Omnivore Veg Vegan] - }, - "site_type" => { - "source" => SITE_TYPES } } } -File.open("output.xlsx", "wb") do |f| - f.write SpreadsheetExporter::XLSX.from_objects(data, options) -end +File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, options)) diff --git a/test_data.rb b/test_data.rb index 9e1c8f6..10cc5cf 100644 --- a/test_data.rb +++ b/test_data.rb @@ -1,53 +1,27 @@ -SITE_TYPES = [ - "Other Practices: Canoe Races", - "Other Practices: Canoe training", - "Other Practices: Healing Place", - "Sxwōxwiyám: Other", +CITIES = [ "Sxwōxwiyám: Sqáyéx/Xwyélés", - "Sxwōxwiyám: Tel Sweyal", - "Sxwōxwiyám: Wōqw'", - "Sxwōxwiyám: X̠éyt", - "Xá:Xa: Burial", - "Xá:Xa: Cave", - "Xá:Xa: Mimestíyexw Place", - "Xá:Xa: Regalia Placement Place", - "Xá:Xa: Shxwexwó:s Cave", - "Xá:Xa: Stl’áleqem Place", - "Xá:Xa: Sxwó:yxwey Origin Place", - "Xá:Xa: S’ó:lmexw Place", - "Xá:Xa: Tunnel", - "Metaphysical Being: Pítxel", - "Metaphysical Being: Shxwexwos", - "Metaphysical Being: Spirit Animal", - "Metaphysical Being: Sásq’ets", - "Spiritual Practice: Power Questing", - "Spiritual Practice: Afterbirth Place", - "Spiritual Practice: Burning Place", - "Spiritual Practice: Ceremonial Feeding", - "Spiritual Practice: Healing Rock", - "Spiritual Practice: Isolation/Puberty Place", - "Spiritual Practice: Prayer Tree", - "Spiritual Practice: Smílha Place", - "Spiritual Practice: Sweats Place", - "Spiritual Practice: Sxwó:yxwey Place", - "Material Culture: Archaeological Site", - "Material Culture: Archaeological Site Lead", - "Material Culture: CMT", - "Material Culture: Surficial Find", - "Material Culture: Symbol", - "Navigation: Geographic Location", - "Resource Management: Burning Place", - "Resource Management: Clam Bed", - "Resource Management: Deer Drive", - "Resource Management: Fishingweir/Trap", - "Resource Management: Irrigation/Spring/Waterway", - "Resource Management: Plantation/Farm", - "Resource Management: Spawning Ground", - "Resource Harvesting: Aquatic Harvesting", - "Resource Harvesting: Aquatic Processing", - "Resource Harvesting: Habitation", - "Resource Harvesting: Hunting", - "Resource Harvesting: Terrestrial Harvesting", - "Resource Harvesting: Trapping", - "Resource Harvesting: Travel" -].map { |s| s.encode("UTF-8")} + "Ceuta", + "Juanhaven", + "佳市", + "East Sarah", + "山武郡横芝光町", + "川崎市宮前区", + "Blondel-sur-Pottier", + "West Christine", + "Lake Amandahaven", + "Weekshaven", + "Breuerscheid", + "Matisscheid", + "Groß Naemidorf", + "Groß Maxim", + "Scharfgrün", + "Neu Liahhagen", + "Eliasstadt", + "Mögenburgscheid", + "Edirne", + "Eskişehir", + "İzmir", + "İstanbul", + "Van", + "Şırnak" +].map { |s| s.encode("UTF-8") } From 879618d88b62102d2af2dfbcb9f96e3b14fd2c18 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Mon, 29 Aug 2022 12:59:02 -0700 Subject: [PATCH 12/26] Refactor to handle reusable validation data sources and named ranges --- README.md | 21 ++++ lib/spreadsheet_exporter.rb | 3 + lib/spreadsheet_exporter/column_validation.rb | 9 ++ lib/spreadsheet_exporter/xlsx.rb | 97 +++++++++---------- test.rb | 57 +++++++---- 5 files changed, 119 insertions(+), 68 deletions(-) create mode 100644 lib/spreadsheet_exporter/column_validation.rb diff --git a/README.md b/README.md index 0e9a8f0..064cf2e 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,24 @@ that is actually comma-delimited, pass ```:col_sep => ','``` as an option when e ```ruby SpreadsheetExporter::CSV.from_spreadsheet([["First Name", "Last Name"], ["Bob", "Hoskins"], ["Roger", "Rabbit"]]) ``` + +### XLSX with Pick Lists + +```ruby +options = { + # data sources are written to a `data` worksheet and may be referenced by + # multiple rows + "data_sources" => { + "food_types" => %w[Polenta Paella Papaya], + }, + "validations" => { + "favourite_food" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "favourite_food", + data_source: "food_types" + ), + "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "yuckiest_food", + data_source: "food_types" + ) + } +``` diff --git a/lib/spreadsheet_exporter.rb b/lib/spreadsheet_exporter.rb index c27e3b2..c7373a0 100644 --- a/lib/spreadsheet_exporter.rb +++ b/lib/spreadsheet_exporter.rb @@ -1,9 +1,12 @@ +require_relative './spreadsheet_exporter/column_validation' require_relative './spreadsheet_exporter/csv' require_relative './spreadsheet_exporter/xlsx' require 'active_support' require 'active_support/core_ext/object/json' module SpreadsheetExporter + VALIDATION_ERROR_TYPES = %w[stop warning information].freeze + begin Mime::Type.register "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", :xlsx rescue NameError diff --git a/lib/spreadsheet_exporter/column_validation.rb b/lib/spreadsheet_exporter/column_validation.rb new file mode 100644 index 0000000..9b1466b --- /dev/null +++ b/lib/spreadsheet_exporter/column_validation.rb @@ -0,0 +1,9 @@ +module SpreadsheetExporter + ColumnValidation = Struct.new(:attribute_name, :ignore_blank, :data_source, :indirect_built_from, :error_type, keyword_init: true) do + def initialize(*) + super + self.ignore_blank = true if ignore_blank.nil? + self.error_type ||= VALIDATION_ERROR_TYPES[0] + end + end +end diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index a9a0beb..d72eb48 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -8,16 +8,6 @@ module XLSX extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` ROW_MAX = 65_536 - 1 - - # Excel allows defining validation `sources` in two different ways, an inline - # list or a reference to cells elsewhere in the workbook. - # - # The inline list is defined as a comma-separated string with a max length of - # 255 characters. - USE_INLINE_LISTS = false # debug toggle, not for production - MAX_INLINE_LIST_CHARS = 255 - - VALIDATION_ERROR_TYPES = %w[stop warning information].freeze DATA_WORKSHEET_NAME = "data".freeze def self.from_objects(objects, options = {}) @@ -46,7 +36,8 @@ def self.from_spreadsheet(spreadsheet, options = {}) worksheet.write_row(row + 1, 0, Array(values)) end - add_worksheet_validation(workbook, worksheet, column_indexes, header_format, options) + data_sources = add_data_sources(workbook, header_format, options) + add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options) workbook.worksheets.each do |ws| ws.freeze_panes(1, 0) @@ -56,6 +47,41 @@ def self.from_spreadsheet(spreadsheet, options = {}) io.string end + def self.add_data_sources(workbook, header_format, options = {}) + data_sources = options.fetch("data_sources", {}) || {} + return {} if data_sources.empty? + + unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) + data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) + end + + data_source_refs = {} + + data_sources.each_with_index do |(data_key, data_values), column_index| + data_source_refs[data_key] = add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) + end + + data_source_refs + end + + # Write a data column to the `data` worksheet and define it as a named range + # + # Returnd the named range's name + def self.add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) + raise ArgumentError unless data_values.is_a?(Array) + + data_start = xl_rowcol_to_cell(1, column_index, true, true) + data_end = xl_rowcol_to_cell(data_values.length, column_index, true, true) + + defined_name_source = "=#{DATA_WORKSHEET_NAME}!#{data_start}:#{data_end}" + + data_sheet.write(0, column_index, data_key, header_format) + data_sheet.write_col(1, column_index, data_values) + defined_name = data_key + workbook.define_name(defined_name, defined_name_source) + defined_name + end + # TODO: we should DRY this up with the Spreadsheet.from_objects logic def self.rewrite_validation_column_names(column_validations, options) return column_validations unless options["humanize_headers_class"] @@ -68,7 +94,7 @@ def self.rewrite_validation_column_names(column_validations, options) end end - def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_format, options = {}) + def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options = {}) column_validations = options.fetch("validations", {}) || {} return if column_validations.empty? @@ -83,7 +109,10 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_fo next end - validation_options = add_column_validation(workbook, column_name, column_index, column_validation, header_format) + defined_name = data_sources[column_validation.data_source] + raise ArgumentError, "missing data for data_source=#{column_validation.data_source}" unless defined_name + + validation_options = add_column_validation(column_validation, defined_name) pp validation_options @@ -91,47 +120,13 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, header_fo end end - def self.add_column_validation(workbook, column_name, column_index, column_validation, header_format) - list_values = Array(column_validation.fetch("source", [])) - if list_values.empty? - raise ArgumentError, "no values for validation for column '#{column_name}'" - end - - error_type = column_validation.fetch("error_type", VALIDATION_ERROR_TYPES[0]) - unless VALIDATION_ERROR_TYPES.include?(error_type) - raise ArgumentError, "invalid error_type `#{error_type}` for validation for column '#{column_name}'" - end - - list_values.compact! - list_length = list_values.join(",").length - - source = nil - - if USE_INLINE_LISTS && list_length <= MAX_INLINE_LIST_CHARS - # commas are not allowed when - # TODO: we should warn about losing any commas - list_values.map! { |v| v.sub(',', '').strip } - source = list_values - else - unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) - data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) - end - - data_start = xl_rowcol_to_cell(1, column_index, true, true) - data_end = xl_rowcol_to_cell(list_values.length, column_index, true, true) - source = "=data!#{data_start}:#{data_end}" - - data_sheet.write(0, column_index, column_name, header_format) - data_sheet.write_col(1, column_index, list_values) - end - + def self.add_column_validation(column_validation, defined_name) { "validate" => "list", "input_title" => "Select a value", - "source" => source, - "error_message" => column_validation.fetch("error_message", "Please select a valid option"), - "error_type" => error_type, - "ignore_blank" => column_validation.fetch("ignore_blank", true), + "source" => "=#{defined_name}", + "error_type" => column_validation.error_type, + "ignore_blank" => column_validation.ignore_blank, "dropdown" => true } end diff --git a/test.rb b/test.rb index 11e7923..5d6f557 100755 --- a/test.rb +++ b/test.rb @@ -10,28 +10,51 @@ # no data in it yet data = [ - {"name" => "Jim", "role" => "admin", "city" => CITIES.sample}, - {"name" => "Sally", "role" => "user"}, - {"name" => "Horatio", "role" => "user", "meal" => "Paleo"}, + {"name" => "Jim", "role" => "admin"}.merge(country_and_city), + {"name" => "Sally", "role" => "user", "favourite_meal" => MEALS.sample, "most_recent_meal" => MEALS.sample}.merge(country_and_city), + {"name" => "Horatio", "role" => "user", "favourite_meal" => MEALS.sample, "most_recent_meal" => MEALS.sample}, {"name" => "Jan", "role" => "user"} ] options = { + "data_sources" => { + "all_meals" => MEALS, + "roles" => %w[admin user spammer boss], + "countries" => CONDITIONAL_CITIES.keys + }, + "validations" => { - "role" => { - "ignore_blank" => false, - "source" => %w[admin user spammer boss] - }, - "city" => { - "ignore_blank" => true, - "error_type" => "information", - "source" => CITIES - }, - "meal" => { - "ignore_blank" => true, - "error_type" => "warning", - "source" => %w[Omnivore Veg Vegan] - } + "role" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "role", + ignore_blank: false, + data_source: "roles" + ), + + "country" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "country", + ignore_blank: true, + error_type: "information", + data_source: "countries" + ), + # "city" => SpreadsheetExporter::ColumnValidation.new( + # :attribute_name => "city", + # :ignore_blank => true, + # :error_type => "information", + # :indirect_built_from => "country", + # :data_source => CONDITIONAL_CITIES + # ), + "favourite_meal" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "favourite_meal", + ignore_blank: true, + error_type: "warning", + data_source: "all_meals" + ), + "most_recent_meal" => SpreadsheetExporter::ColumnValidation.new( + attribute_name: "most_recent_meal", + ignore_blank: true, + error_type: "warning", + data_source: "all_meals" + ) } } From 5c3663f47edbd6bbd2971bb114793b84cb44bda4 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Mon, 29 Aug 2022 18:23:27 -0700 Subject: [PATCH 13/26] Add logic for dependent pick lists --- README.md | 3 +- lib/spreadsheet_exporter/column_validation.rb | 2 +- lib/spreadsheet_exporter/spreadsheet.rb | 11 +++- lib/spreadsheet_exporter/xlsx.rb | 62 +++++++++++++++---- test.rb | 20 +++--- test_data.rb | 53 ++++++++-------- 6 files changed, 94 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 064cf2e..9a8e541 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,13 @@ options = { # multiple rows "data_sources" => { "food_types" => %w[Polenta Paella Papaya], + "countries" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]} }, "validations" => { "favourite_food" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "favourite_food", data_source: "food_types" ), "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "yuckiest_food", data_source: "food_types" ) } diff --git a/lib/spreadsheet_exporter/column_validation.rb b/lib/spreadsheet_exporter/column_validation.rb index 9b1466b..782ea3a 100644 --- a/lib/spreadsheet_exporter/column_validation.rb +++ b/lib/spreadsheet_exporter/column_validation.rb @@ -1,5 +1,5 @@ module SpreadsheetExporter - ColumnValidation = Struct.new(:attribute_name, :ignore_blank, :data_source, :indirect_built_from, :error_type, keyword_init: true) do + ColumnValidation = Struct.new(:ignore_blank, :data_source, :indirect_built_from, :error_type, keyword_init: true) do def initialize(*) super self.ignore_blank = true if ignore_blank.nil? diff --git a/lib/spreadsheet_exporter/spreadsheet.rb b/lib/spreadsheet_exporter/spreadsheet.rb index 0baf9a7..a6b9d9c 100644 --- a/lib/spreadsheet_exporter/spreadsheet.rb +++ b/lib/spreadsheet_exporter/spreadsheet.rb @@ -7,8 +7,15 @@ def self.from_objects(objects, options = {}) # Get all the data and accumulate headers from each row (since rows may not have all the same attributes) Array(objects).each do |object| - data = object.respond_to?(:as_csv) ? get_values(object.as_csv(options)) : get_values(object.as_json(options)) - headers = headers | data.keys + data = if object.respond_to?(:as_spreadsheet) + get_values(object.as_spreadsheet(options)) + elsif object.respond_to?(:as_csv) + get_values(object.as_csv(options)) + else + get_values(object.as_json(options)) + end + + headers |= data.keys rows << data end diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index d72eb48..4b8f0a9 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -37,6 +37,7 @@ def self.from_spreadsheet(spreadsheet, options = {}) end data_sources = add_data_sources(workbook, header_format, options) + pp data_sources add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options) workbook.worksheets.each do |ws| @@ -47,6 +48,10 @@ def self.from_spreadsheet(spreadsheet, options = {}) io.string end + def self.sanitize_defined_name(raw) + raw.gsub(/[^A-Za-z0-9_]/, "_") + end + def self.add_data_sources(workbook, header_format, options = {}) data_sources = options.fetch("data_sources", {}) || {} return {} if data_sources.empty? @@ -57,8 +62,19 @@ def self.add_data_sources(workbook, header_format, options = {}) data_source_refs = {} - data_sources.each_with_index do |(data_key, data_values), column_index| - data_source_refs[data_key] = add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) + column_index = 0 + data_sources.each do |data_key, data_values| + if data_values.is_a?(Hash) + # nested, conditional data structure + data_values.each do |data_value, sub_values| + sub_key = sanitize_defined_name("#{data_key}_#{data_value}") + data_source_refs[sub_key] = add_data_source(workbook, data_sheet, sub_key, sub_values, column_index, header_format) + column_index += 1 + end + else + data_source_refs[data_key] = add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) + column_index += 1 + end end data_source_refs @@ -68,7 +84,10 @@ def self.add_data_sources(workbook, header_format, options = {}) # # Returnd the named range's name def self.add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) - raise ArgumentError unless data_values.is_a?(Array) + unless data_values.is_a?(Array) + debugger + raise ArgumentError, "data_values should be an array (got #{data_values.inspect}" + end data_start = xl_rowcol_to_cell(1, column_index, true, true) data_end = xl_rowcol_to_cell(data_values.length, column_index, true, true) @@ -76,7 +95,7 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind defined_name_source = "=#{DATA_WORKSHEET_NAME}!#{data_start}:#{data_end}" data_sheet.write(0, column_index, data_key, header_format) - data_sheet.write_col(1, column_index, data_values) + data_sheet.write_col(1, column_index, data_values.map(&:strip)) defined_name = data_key workbook.define_name(defined_name, defined_name_source) defined_name @@ -85,6 +104,7 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind # TODO: we should DRY this up with the Spreadsheet.from_objects logic def self.rewrite_validation_column_names(column_validations, options) return column_validations unless options["humanize_headers_class"] + klass = options["humanize_headers_class"] column_validations.each_with_object({}) do |(attribute, v), obj| @@ -109,18 +129,34 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour next end - defined_name = data_sources[column_validation.data_source] - raise ArgumentError, "missing data for data_source=#{column_validation.data_source}" unless defined_name - - validation_options = add_column_validation(column_validation, defined_name) - - pp validation_options - - worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) + # Excel's `INDIRECT` function lets us build up the name of a defined range dynamically + if column_validation.indirect_built_from + parent_column_index = column_indexes[column_validation.indirect_built_from] + + (1..100).each do |row_index| + indirect_cell = xl_rowcol_to_cell(row_index, parent_column_index, false, false) + defined_name = "INDIRECT(\"#{column_validation.data_source}\" & \"_\" & SUBSTITUTE(#{indirect_cell}, \" \", \"\"))" + + validation_options = generate_validation(column_validation, defined_name) + pp validation_options + worksheet.data_validation(row_index, column_index, validation_options) + end + else + defined_name = data_sources[column_validation.data_source] + unless defined_name + raise ArgumentError, "missing data for data_source=#{column_validation.data_source}" + end + + validation_options = generate_validation(column_validation, defined_name) + pp validation_options + worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) + end + rescue StandardError => e + debugger end end - def self.add_column_validation(column_validation, defined_name) + def self.generate_validation(column_validation, defined_name) { "validate" => "list", "input_title" => "Select a value", diff --git a/test.rb b/test.rb index 5d6f557..4cd8af4 100755 --- a/test.rb +++ b/test.rb @@ -20,37 +20,33 @@ "data_sources" => { "all_meals" => MEALS, "roles" => %w[admin user spammer boss], - "countries" => CONDITIONAL_CITIES.keys + "countries" => COUNTRIES, + "cities" => CONDITIONAL_CITIES }, "validations" => { "role" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "role", ignore_blank: false, data_source: "roles" ), "country" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "country", ignore_blank: true, error_type: "information", data_source: "countries" ), - # "city" => SpreadsheetExporter::ColumnValidation.new( - # :attribute_name => "city", - # :ignore_blank => true, - # :error_type => "information", - # :indirect_built_from => "country", - # :data_source => CONDITIONAL_CITIES - # ), + "city" => SpreadsheetExporter::ColumnValidation.new( + ignore_blank: true, + error_type: "information", + indirect_built_from: "country", + data_source: "cities" + ), "favourite_meal" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "favourite_meal", ignore_blank: true, error_type: "warning", data_source: "all_meals" ), "most_recent_meal" => SpreadsheetExporter::ColumnValidation.new( - attribute_name: "most_recent_meal", ignore_blank: true, error_type: "warning", data_source: "all_meals" diff --git a/test_data.rb b/test_data.rb index 10cc5cf..ec9b5e6 100644 --- a/test_data.rb +++ b/test_data.rb @@ -1,27 +1,26 @@ -CITIES = [ - "Sxwōxwiyám: Sqáyéx/Xwyélés", - "Ceuta", - "Juanhaven", - "佳市", - "East Sarah", - "山武郡横芝光町", - "川崎市宮前区", - "Blondel-sur-Pottier", - "West Christine", - "Lake Amandahaven", - "Weekshaven", - "Breuerscheid", - "Matisscheid", - "Groß Naemidorf", - "Groß Maxim", - "Scharfgrün", - "Neu Liahhagen", - "Eliasstadt", - "Mögenburgscheid", - "Edirne", - "Eskişehir", - "İzmir", - "İstanbul", - "Van", - "Şırnak" -].map { |s| s.encode("UTF-8") } +def country_and_city + country = sample_country + city = CONDITIONAL_CITIES[country].sample + {country: country, city: city} +end + +def sample_country + CONDITIONAL_CITIES.keys.sample +end + +MEALS = %w[Omnivore Veg Vegan] + +# COUNTRIES = %w[Canada Türkiye] +COUNTRIES = %w[Canada Turkey] + +CONDITIONAL_CITIES = { + COUNTRIES[0] => [ + "Sxwōxwiyám", + "Toronto" + ].map { |s| s.encode("UTF-8") }, + COUNTRIES[1] => [ + "Eskişehir", + "İzmir", + "İstanbul" + ].map { |s| s.encode("UTF-8") } +} From bd2545a7d0101c23a821a3a8a110b990c8c3bfef Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Mon, 29 Aug 2022 19:01:42 -0700 Subject: [PATCH 14/26] Add HeaderCell to simplify humanizing names --- lib/spreadsheet_exporter.rb | 1 + lib/spreadsheet_exporter/spreadsheet.rb | 33 +++++++++++++++++-------- lib/spreadsheet_exporter/xlsx.rb | 24 ++++-------------- test.rb | 10 ++++++++ 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/lib/spreadsheet_exporter.rb b/lib/spreadsheet_exporter.rb index c7373a0..195a2c5 100644 --- a/lib/spreadsheet_exporter.rb +++ b/lib/spreadsheet_exporter.rb @@ -3,6 +3,7 @@ require_relative './spreadsheet_exporter/xlsx' require 'active_support' require 'active_support/core_ext/object/json' +require 'active_support/core_ext/hash/reverse_merge' module SpreadsheetExporter VALIDATION_ERROR_TYPES = %w[stop warning information].freeze diff --git a/lib/spreadsheet_exporter/spreadsheet.rb b/lib/spreadsheet_exporter/spreadsheet.rb index a6b9d9c..820dac8 100644 --- a/lib/spreadsheet_exporter/spreadsheet.rb +++ b/lib/spreadsheet_exporter/spreadsheet.rb @@ -1,5 +1,12 @@ # TODO: Find out why we can't detect arrays properly and must resort to crappy class.name comparison module SpreadsheetExporter + HeaderCell = Struct.new(:attribute_name, :human_attribute_name) do + def to_s + human_attribute_name.presence || attribute_name + end + end + + module Spreadsheet def self.from_objects(objects, options = {}) headers = [] @@ -15,17 +22,23 @@ def self.from_objects(objects, options = {}) get_values(object.as_json(options)) end - headers |= data.keys + headers |= data.keys.map { |v| HeaderCell.new(v) } rows << data end # Create the csv, ensuring to place each row's attributes under the appropriate header (since rows may not have all the same attributes) [].tap do |spreadsheet| - spreadsheet << (options[:humanize_headers_class] ? han(options[:humanize_headers_class], headers) : headers) + if options[:humanize_headers_class] + headers = han(headers, **options) + end + + spreadsheet << headers + rows.each do |row| sorted_row = [] row.each do |header, value| - sorted_row[headers.index(header)] = value + col_index = headers.find_index { |h| h.attribute_name == header } + sorted_row[col_index] = value end spreadsheet << sorted_row @@ -35,14 +48,14 @@ def self.from_objects(objects, options = {}) # Return an array of human_attribute_name's # Used by the CSV Import/Export process to match CSV headers to model attribute names - def self.han(klass, *attributes) - options = attributes.extract_options! + def self.han(headers, humanize_headers_class:, downcase: false, **) + headers.flatten! - attributes.flatten! - attributes.collect! {|attribute| klass.human_attribute_name(attribute) } - attributes.collect!(&:downcase) if options[:downcase] - - return attributes.many? ? attributes : attributes.first + headers.collect! do |header| + header.human_attribute_name = humanize_headers_class.human_attribute_name(header.attribute_name) + header.human_attribute_name.downcase! if downcase + header + end end def self.get_values(node, current_header = nil) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 4b8f0a9..9ffbb71 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -27,9 +27,9 @@ def self.from_spreadsheet(spreadsheet, options = {}) column_indexes = {} # Write header row - Array(spreadsheet.first).each_with_index do |column_name, col| - worksheet.write(0, col, column_name, header_format) - column_indexes[column_name] = col + Array(spreadsheet.first).each_with_index do |header, col| + worksheet.write(0, col, header.to_s, header_format) + column_indexes[header.attribute_name] = col end Array(spreadsheet[1..]).each_with_index do |values, row| @@ -101,25 +101,10 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind defined_name end - # TODO: we should DRY this up with the Spreadsheet.from_objects logic - def self.rewrite_validation_column_names(column_validations, options) - return column_validations unless options["humanize_headers_class"] - - klass = options["humanize_headers_class"] - - column_validations.each_with_object({}) do |(attribute, v), obj| - rewritten = klass.human_attribute_name(attribute) - rewritten.downcase! if options[:downcase] - obj[rewritten] = v - end - end - def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options = {}) column_validations = options.fetch("validations", {}) || {} return if column_validations.empty? - column_validations = rewrite_validation_column_names(column_validations, options) - column_validations.each do |column_name, column_validation| column_index = column_indexes[column_name] @@ -133,7 +118,8 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour if column_validation.indirect_built_from parent_column_index = column_indexes[column_validation.indirect_built_from] - (1..100).each do |row_index| + # TODO: que pasa + (1..20).each do |row_index| indirect_cell = xl_rowcol_to_cell(row_index, parent_column_index, false, false) defined_name = "INDIRECT(\"#{column_validation.data_source}\" & \"_\" & SUBSTITUTE(#{indirect_cell}, \" \", \"\"))" diff --git a/test.rb b/test.rb index 4cd8af4..532d20f 100755 --- a/test.rb +++ b/test.rb @@ -53,5 +53,15 @@ ) } } +class Yoda + def self.human_attribute_name(att) + att.reverse + end +end + +# debugger +# SpreadsheetExporter::CSV.from_objects(data, :humanize_headers_class => Yoda) + +options[:humanize_headers_class] = Yoda File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, options)) From 2832fb1156d05e95e2131b68f39cf44cecd9fe1e Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 09:28:39 -0700 Subject: [PATCH 15/26] Docs --- lib/spreadsheet_exporter/xlsx.rb | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 9ffbb71..5305149 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -36,7 +36,7 @@ def self.from_spreadsheet(spreadsheet, options = {}) worksheet.write_row(row + 1, 0, Array(values)) end - data_sources = add_data_sources(workbook, header_format, options) + data_sources = add_data_sources(workbook, header_format, options.fetch("data_sources", {}) || {}) pp data_sources add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options) @@ -52,8 +52,20 @@ def self.sanitize_defined_name(raw) raw.gsub(/[^A-Za-z0-9_]/, "_") end - def self.add_data_sources(workbook, header_format, options = {}) - data_sources = options.fetch("data_sources", {}) || {} + # Write each data_source to the `data` worksheet and reference it with a named range + # + # `data_sources` is a hash in the format + # + # { 'data_source_id' => ['data', 'source', 'options'] } + # + # For data sources dependent on the value in another column, the format is + # + # { 'data_source_id' => { + # 'other_col_val_1' => ['options', 'when', 'val is 1'], + # 'other_col_val_2' => ['options', 'when', 'val is 2'] + # } + # } + def self.add_data_sources(workbook, header_format, data_sources) return {} if data_sources.empty? unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) From 383d4f68c970dfbd3ed522e5ecadd7e6996ab779 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 10:29:01 -0700 Subject: [PATCH 16/26] Add freeze_panes option --- README.md | 3 ++- lib/spreadsheet_exporter/xlsx.rb | 12 +++++++++--- test.rb | 9 +++++---- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9a8e541..2674785 100644 --- a/README.md +++ b/README.md @@ -46,5 +46,6 @@ options = { "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( data_source: "food_types" ) - } + }, + "freeze_panes" => [1, 0] # number of rows and columns to freeze (only applies to XLSX) ``` diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 5305149..20dab45 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -40,9 +40,7 @@ def self.from_spreadsheet(spreadsheet, options = {}) pp data_sources add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options) - workbook.worksheets.each do |ws| - ws.freeze_panes(1, 0) - end + freeze_panes(worksheet, options) workbook.close io.string @@ -52,6 +50,13 @@ def self.sanitize_defined_name(raw) raw.gsub(/[^A-Za-z0-9_]/, "_") end + # freeze_panes => [1, 2] # freeze the top row and left two cols + def self.freeze_panes(worksheet, options = {}) + return unless options["freeze_panes"] + rows, cols = options["freeze_panes"] + worksheet.freeze_panes(Integer(rows), Integer(cols)) + end + # Write each data_source to the `data` worksheet and reference it with a named range # # `data_sources` is a hash in the format @@ -70,6 +75,7 @@ def self.add_data_sources(workbook, header_format, data_sources) unless (data_sheet = workbook.worksheet_by_name(DATA_WORKSHEET_NAME)) data_sheet = workbook.add_worksheet(DATA_WORKSHEET_NAME) + data_sheet.freeze_panes(1, 0) end data_source_refs = {} diff --git a/test.rb b/test.rb index 532d20f..cc0c9d1 100755 --- a/test.rb +++ b/test.rb @@ -53,15 +53,16 @@ ) } } -class Yoda +class Humanizer def self.human_attribute_name(att) - att.reverse + att.upcase end end # debugger -# SpreadsheetExporter::CSV.from_objects(data, :humanize_headers_class => Yoda) +# SpreadsheetExporter::CSV.from_objects(data, :humanize_headers_class => Humanizer) -options[:humanize_headers_class] = Yoda +options[:humanize_headers_class] = Humanizer +options[:freeze_panes] = [1, 4] File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, options)) From 2cd0fe2bcbb2147a39565cdd1cbf276622f7b3ed Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 10:29:17 -0700 Subject: [PATCH 17/26] Add GeneratesSpreadsheet model concern --- lib/spreadsheet_exporter.rb | 1 + lib/spreadsheet_exporter/generates_spreadsheet.rb | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 lib/spreadsheet_exporter/generates_spreadsheet.rb diff --git a/lib/spreadsheet_exporter.rb b/lib/spreadsheet_exporter.rb index 195a2c5..7c210a4 100644 --- a/lib/spreadsheet_exporter.rb +++ b/lib/spreadsheet_exporter.rb @@ -1,3 +1,4 @@ +require_relative './spreadsheet_exporter/generates_spreadsheet' require_relative './spreadsheet_exporter/column_validation' require_relative './spreadsheet_exporter/csv' require_relative './spreadsheet_exporter/xlsx' diff --git a/lib/spreadsheet_exporter/generates_spreadsheet.rb b/lib/spreadsheet_exporter/generates_spreadsheet.rb new file mode 100644 index 0000000..27f8ad2 --- /dev/null +++ b/lib/spreadsheet_exporter/generates_spreadsheet.rb @@ -0,0 +1,11 @@ +require "active_support/concern" + +module SpreadsheetExporter + module GeneratesSpreadsheet + extend ActiveSupport::Concern + + def as_spreadsheet(options = {}) + serializable_hash(options) + end + end +end From 2c5a327f8a90e7160598d4cb72f8f0d766d1bce7 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 11:38:16 -0700 Subject: [PATCH 18/26] =?UTF-8?q?Prevent=20having=20to=20write=20per-row?= =?UTF-8?q?=20dependent=20validations=20=F0=9F=A5=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++++- lib/spreadsheet_exporter/column_validation.rb | 2 +- lib/spreadsheet_exporter/xlsx.rb | 49 ++++++++++++------- test.rb | 5 +- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2674785..85caf48 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ options = { # multiple rows "data_sources" => { "food_types" => %w[Polenta Paella Papaya], - "countries" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]} + "countries" => %w[Canada Türkiye], + "cities" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]} }, "validations" => { "favourite_food" => SpreadsheetExporter::ColumnValidation.new( @@ -45,7 +46,14 @@ options = { ), "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( data_source: "food_types" - ) + ), + "country" => SpreadsheetExporter::ColumnValidation.new( + data_source: "countries" + ), + "city" => SpreadsheetExporter::ColumnValidation.new( + dependent_on: "country", + data_source: "cities" + ), }, "freeze_panes" => [1, 0] # number of rows and columns to freeze (only applies to XLSX) ``` diff --git a/lib/spreadsheet_exporter/column_validation.rb b/lib/spreadsheet_exporter/column_validation.rb index 782ea3a..fdab080 100644 --- a/lib/spreadsheet_exporter/column_validation.rb +++ b/lib/spreadsheet_exporter/column_validation.rb @@ -1,5 +1,5 @@ module SpreadsheetExporter - ColumnValidation = Struct.new(:ignore_blank, :data_source, :indirect_built_from, :error_type, keyword_init: true) do + ColumnValidation = Struct.new(:ignore_blank, :data_source, :dependent_on, :error_type, keyword_init: true) do def initialize(*) super self.ignore_blank = true if ignore_blank.nil? diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 20dab45..6e1efe3 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -5,7 +5,7 @@ module SpreadsheetExporter module XLSX - extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` + extend Writexlsx::Utility # gets us `xl_rowcol_to_cell` and `xl_col_to_name` ROW_MAX = 65_536 - 1 DATA_WORKSHEET_NAME = "data".freeze @@ -127,39 +127,50 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour column_index = column_indexes[column_name] if column_index.nil? - # TODO: we should output an empty column anyways warn "attempted to apply validation to missing column '#{column_name}'" next end - # Excel's `INDIRECT` function lets us build up the name of a defined range dynamically - if column_validation.indirect_built_from - parent_column_index = column_indexes[column_validation.indirect_built_from] + defined_name = nil - # TODO: que pasa - (1..20).each do |row_index| - indirect_cell = xl_rowcol_to_cell(row_index, parent_column_index, false, false) - defined_name = "INDIRECT(\"#{column_validation.data_source}\" & \"_\" & SUBSTITUTE(#{indirect_cell}, \" \", \"\"))" + if column_validation.dependent_on + # parent_col is the column we listen to for changes and then update the dependent columns + # valid options + parent_col_index = column_indexes[column_validation.dependent_on] + parent_col = xl_col_to_name(parent_col_index, true) - validation_options = generate_validation(column_validation, defined_name) - pp validation_options - worksheet.data_validation(row_index, column_index, validation_options) - end + defined_name = dependent_named_range(column_validation.data_source, parent_col) else defined_name = data_sources[column_validation.data_source] - unless defined_name - raise ArgumentError, "missing data for data_source=#{column_validation.data_source}" - end + end - validation_options = generate_validation(column_validation, defined_name) - pp validation_options - worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) + unless defined_name + raise ArgumentError, "missing data for data_source=#{column_validation.data_source}, " \ + "tried defined_name #{defined_name}" end + + + validation_options = generate_validation(column_validation, defined_name) + pp validation_options + worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) rescue StandardError => e debugger end end + # We build up the reference to the named range by leaning on Excel's INDIRECT function + # to dynamically build the name. The resulting formula becomes the validation drop down's + # source. It resolves thusly... + # + # INDIRECT("sub_data_source" & "_" & SUBSTITUTE(INDIRECT("$AA" & ROW()), " ", "_")) + # INDIRECT("sub_data_source" & "_" & SUBSTITUTE("Parent Value, " ", "_")) + # INDIRECT("sub_data_source" & "_" & "Parent_Value") + # INDIRECT("sub_data_source_Parent_Value") + def self.dependent_named_range(data_source, parent_col) + "INDIRECT(\"#{data_source}\" & \"_\" & "\ + "SUBSTITUTE(INDIRECT(\"#{parent_col}\" & ROW()), \" \", \"_\"))" + end + def self.generate_validation(column_validation, defined_name) { "validate" => "list", diff --git a/test.rb b/test.rb index cc0c9d1..ec5c7e5 100755 --- a/test.rb +++ b/test.rb @@ -29,7 +29,6 @@ ignore_blank: false, data_source: "roles" ), - "country" => SpreadsheetExporter::ColumnValidation.new( ignore_blank: true, error_type: "information", @@ -38,7 +37,7 @@ "city" => SpreadsheetExporter::ColumnValidation.new( ignore_blank: true, error_type: "information", - indirect_built_from: "country", + dependent_on: "country", data_source: "cities" ), "favourite_meal" => SpreadsheetExporter::ColumnValidation.new( @@ -63,6 +62,6 @@ def self.human_attribute_name(att) # SpreadsheetExporter::CSV.from_objects(data, :humanize_headers_class => Humanizer) options[:humanize_headers_class] = Humanizer -options[:freeze_panes] = [1, 4] +options[:freeze_panes] = [1, 1] File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, options)) From 661d5a6a957ececd1490a0d64a3404441ec74d7e Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 12:22:24 -0700 Subject: [PATCH 19/26] Tighten --- lib/spreadsheet_exporter/xlsx.rb | 35 ++++++++++++++------------------ 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 6e1efe3..4c32c32 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -114,9 +114,9 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind data_sheet.write(0, column_index, data_key, header_format) data_sheet.write_col(1, column_index, data_values.map(&:strip)) - defined_name = data_key - workbook.define_name(defined_name, defined_name_source) - defined_name + workbook.define_name(data_key, defined_name_source) + + data_key end def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options = {}) @@ -131,25 +131,19 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour next end - defined_name = nil - - if column_validation.dependent_on - # parent_col is the column we listen to for changes and then update the dependent columns - # valid options - parent_col_index = column_indexes[column_validation.dependent_on] - parent_col = xl_col_to_name(parent_col_index, true) - - defined_name = dependent_named_range(column_validation.data_source, parent_col) - else - defined_name = data_sources[column_validation.data_source] - end + defined_name = if column_validation.dependent_on + parent_col_index = column_indexes[column_validation.dependent_on] + parent_col = xl_col_to_name(parent_col_index, true) + dependent_named_range(column_validation.data_source, parent_col) + else + data_sources[column_validation.data_source] + end unless defined_name raise ArgumentError, "missing data for data_source=#{column_validation.data_source}, " \ "tried defined_name #{defined_name}" end - validation_options = generate_validation(column_validation, defined_name) pp validation_options worksheet.data_validation(1, column_index, ROW_MAX, column_index, validation_options) @@ -162,10 +156,11 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour # to dynamically build the name. The resulting formula becomes the validation drop down's # source. It resolves thusly... # - # INDIRECT("sub_data_source" & "_" & SUBSTITUTE(INDIRECT("$AA" & ROW()), " ", "_")) - # INDIRECT("sub_data_source" & "_" & SUBSTITUTE("Parent Value, " ", "_")) - # INDIRECT("sub_data_source" & "_" & "Parent_Value") - # INDIRECT("sub_data_source_Parent_Value") + # =INDIRECT("sub_data_source" & "_" & SUBSTITUTE(INDIRECT("$AA" & ROW()), " ", "_")) + # =INDIRECT("sub_data_source" & "_" & SUBSTITUTE("Parent Value, " ", "_")) + # =INDIRECT("sub_data_source" & "_" & "Parent_Value") + # =INDIRECT("sub_data_source_Parent_Value") + # =sub_data_source_Parent_Value def self.dependent_named_range(data_source, parent_col) "INDIRECT(\"#{data_source}\" & \"_\" & "\ "SUBSTITUTE(INDIRECT(\"#{parent_col}\" & ROW()), \" \", \"_\"))" From fc5d43375c1a32f7e2316a0a41d790f98055c3fb Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Tue, 30 Aug 2022 13:01:31 -0700 Subject: [PATCH 20/26] Add macros to source control --- macros.vb | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 macros.vb diff --git a/macros.vb b/macros.vb new file mode 100644 index 0000000..b30114e --- /dev/null +++ b/macros.vb @@ -0,0 +1,96 @@ +Option Explicit + +' https://stackoverflow.com/a/48375276/559596 +Public Function ExistsInCollection(col As Collection, key As Variant) As Boolean + On Error GoTo err + ExistsInCollection = True + IsObject(col.item(key)) + Exit Function +err: + ExistsInCollection = False +End Function + +' https://stackoverflow.com/a/47500463/559596 +Public Function CollectionToArray(myCol As Collection) As Variant + Dim result As Variant + Dim cnt As Long + + ReDim result(myCol.Count - 1) + + For cnt = 0 To myCol.Count - 1 + result(cnt) = myCol(cnt + 1) + Next cnt + + CollectionToArray = result +End Function + +Private Sub Worksheet_Change(ByVal Target As Range) + Dim existingValue As String + Dim toggledValue As String + Dim tokenArr() As String + Dim tokenCollection As Collection + Dim token as Variant + + ' The column containing the parent type ("site type") - we watch this for changes and then update + ' the dependent columns accordingly + Dim ParentTypeCol As String + ParentTypeCol = "AS" + + ' All the columns that are dependent on the parent type ("site type") + Dim DependentTypeStartCol As String + Dim DependentTypeEndCol As String + DependentTypeStartCol = "AT" + DependentTypeEndCol = "AZ" + + Dim DependentTypeRangeSelector As String + DependentTypeRangeSelector = DependentTypeStartCol & Target.Row & ":" & DependentTypeEndCol & Target.Row + + If Intersect(Target, Range(ParentTypeCol & ":" & ParentTypeCol & "," & DependentTypeRangeSelector)) Is Nothing Then Exit Sub + + ' If an error occurs, enable events and quit the code + On Error GoTo Quit + + Application.EnableEvents = False + Application.ScreenUpdating = False + + ' If we change anything in the parent-type col then clear all the dependent types + If Not Intersect(Target, Range(ParentTypeCol & ":" & ParentTypeCol)) Is Nothing Then + Debug.Print "in col for clearing... " & DependentTypeRangeSelector + Range(DependentTypeRangeSelector).ClearContents + GoTo Quit + End If + + ' Handle pick-list changes + If Not Intersect(Target, Range(DependentTypeRangeSelector)) Is Nothing Then + ' If user deletes the dropdown cell's data do nothing + If Target.Value = "" Then GoTo Quit + + ' If we already have a comma we assume this is the result of copy-and-pasting + ' and we bail early + toggledValue = Target.Value + If InStr(toggledValue, ",") > 0 Then GoTo Quit + + Application.Undo + + existingValue = Target.Value + + tokenArr() = Split(existingValue, ",") + Set tokenCollection = New Collection + For Each token in tokenArr + tokenCollection.Add Trim(token), Trim(token) + Next + + + If ExistsInCollection(tokenCollection, toggledValue) Then + tokenCollection.Remove toggledValue + Else + tokenCollection.Add Trim(toggledValue), Trim(toggledValue) + End If + + Target.Value = Join(CollectionToArray(tokenCollection), ",") + End If + +Quit: + Application.EnableEvents = True + Application.ScreenUpdating = True +End Sub From 1f3154686601e84f268124769b7ab17ffbdc53b7 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 7 Sep 2022 12:13:42 -0700 Subject: [PATCH 21/26] Use kwargs --- lib/spreadsheet_exporter/csv.rb | 10 ++++---- lib/spreadsheet_exporter/spreadsheet.rb | 7 +++--- lib/spreadsheet_exporter/xlsx.rb | 33 ++++++++++++------------- test.rb | 6 ++--- 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/lib/spreadsheet_exporter/csv.rb b/lib/spreadsheet_exporter/csv.rb index ea6704c..2152ec5 100644 --- a/lib/spreadsheet_exporter/csv.rb +++ b/lib/spreadsheet_exporter/csv.rb @@ -5,19 +5,19 @@ module SpreadsheetExporter module CSV BOM = "\377\376".force_encoding("utf-16le") # Byte Order Mark so Excel displays characters correctly - def self.from_objects(objects, options = {}) - spreadsheet = Spreadsheet.from_objects(objects, options).compact + def self.from_objects(objects, humanize_headers_class: nil, **options) + spreadsheet = Spreadsheet.from_objects(objects, humanize_headers_class: humanize_headers_class, **options).compact from_spreadsheet(spreadsheet) end - def self.from_spreadsheet(spreadsheet, options = {}) - output = ::CSV.generate(**options.reverse_merge(:encoding => 'UTF-8', :col_sep => "\t")) do |csv| + def self.from_spreadsheet(spreadsheet, encoding: 'UTF-8', col_sep: "\t", **options) + output = ::CSV.generate(encoding: encoding, col_sep: col_sep, **options) do |csv| spreadsheet.each do |row| csv << row end end - return BOM + output.encode!('utf-16le') + BOM + output.encode!('utf-16le') end end end diff --git a/lib/spreadsheet_exporter/spreadsheet.rb b/lib/spreadsheet_exporter/spreadsheet.rb index 820dac8..e958386 100644 --- a/lib/spreadsheet_exporter/spreadsheet.rb +++ b/lib/spreadsheet_exporter/spreadsheet.rb @@ -6,9 +6,8 @@ def to_s end end - module Spreadsheet - def self.from_objects(objects, options = {}) + def self.from_objects(objects, humanize_headers_class: nil, **options) headers = [] rows = [] @@ -28,8 +27,8 @@ def self.from_objects(objects, options = {}) # Create the csv, ensuring to place each row's attributes under the appropriate header (since rows may not have all the same attributes) [].tap do |spreadsheet| - if options[:humanize_headers_class] - headers = han(headers, **options) + if humanize_headers_class + headers = han(headers, humanize_headers_class: humanize_headers_class, **options) end spreadsheet << headers diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index 4c32c32..c49c959 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -10,12 +10,12 @@ module XLSX ROW_MAX = 65_536 - 1 DATA_WORKSHEET_NAME = "data".freeze - def self.from_objects(objects, options = {}) - spreadsheet = Spreadsheet.from_objects(objects, options).compact - from_spreadsheet(spreadsheet, options.deep_stringify_keys) + def self.from_objects(objects, humanize_headers_class: nil, **options) + spreadsheet = Spreadsheet.from_objects(objects, humanize_headers_class: humanize_headers_class, **options).compact + from_spreadsheet(spreadsheet, **options) end - def self.from_spreadsheet(spreadsheet, options = {}) + def self.from_spreadsheet(spreadsheet, validations: {}, data_sources: {}, freeze_panes: false, **options) io = StringIO.new workbook = WriteXLSX.new(io) @@ -36,11 +36,11 @@ def self.from_spreadsheet(spreadsheet, options = {}) worksheet.write_row(row + 1, 0, Array(values)) end - data_sources = add_data_sources(workbook, header_format, options.fetch("data_sources", {}) || {}) - pp data_sources - add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options) + added_data_sources = add_data_sources(workbook, header_format, data_sources) - freeze_panes(worksheet, options) + add_worksheet_validation(workbook, worksheet, column_indexes, added_data_sources, header_format, options) + + add_frozen_panes(worksheet, freeze_panes) workbook.close io.string @@ -51,9 +51,9 @@ def self.sanitize_defined_name(raw) end # freeze_panes => [1, 2] # freeze the top row and left two cols - def self.freeze_panes(worksheet, options = {}) - return unless options["freeze_panes"] - rows, cols = options["freeze_panes"] + def self.add_frozen_panes(worksheet, freeze_panes) + return unless freeze_panes + rows, cols = freeze_panes worksheet.freeze_panes(Integer(rows), Integer(cols)) end @@ -81,7 +81,7 @@ def self.add_data_sources(workbook, header_format, data_sources) data_source_refs = {} column_index = 0 - data_sources.each do |data_key, data_values| + data_sources.stringify_keys.each do |data_key, data_values| if data_values.is_a?(Hash) # nested, conditional data structure data_values.each do |data_value, sub_values| @@ -119,12 +119,11 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind data_key end - def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, options = {}) - column_validations = options.fetch("validations", {}) || {} - return if column_validations.empty? + def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, validations) + return if validations.empty? - column_validations.each do |column_name, column_validation| - column_index = column_indexes[column_name] + validations.each do |column_name, column_validation| + column_index = column_indexes[column_name.to_s] if column_index.nil? warn "attempted to apply validation to missing column '#{column_name}'" diff --git a/test.rb b/test.rb index ec5c7e5..570a491 100755 --- a/test.rb +++ b/test.rb @@ -17,14 +17,14 @@ ] options = { - "data_sources" => { + :data_sources => { "all_meals" => MEALS, "roles" => %w[admin user spammer boss], "countries" => COUNTRIES, "cities" => CONDITIONAL_CITIES }, - "validations" => { + :validations => { "role" => SpreadsheetExporter::ColumnValidation.new( ignore_blank: false, data_source: "roles" @@ -64,4 +64,4 @@ def self.human_attribute_name(att) options[:humanize_headers_class] = Humanizer options[:freeze_panes] = [1, 1] -File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, options)) +File.binwrite("output.xlsx", SpreadsheetExporter::XLSX.from_objects(data, **options)) From eb5285c60a1be7d5d3ebdd7008f5fb8f2a073f0b Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 7 Sep 2022 12:31:17 -0700 Subject: [PATCH 22/26] Docs --- README.md | 58 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 85caf48..36ec598 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ gem 'spreadsheet_exporter' ## Usage -Objects that are exported must respond to ```as_csv``` or ```as_json``` and return a hash -representing column names and values. +Objects that are exported must respond to `as_spreadsheet`, `as_csv` or `as_json` and return a hash +representing column names and values. In Rails you can `include SpreadsheetExporter::GeneratesSpreadsheet` into your model. + ### CSV or XLSX Output can be .csv or .xlsx. Choose by using SpreadsheetExporter::CSV or SpreadsheetExporter::XLSX modules. @@ -32,28 +33,33 @@ that is actually comma-delimited, pass ```:col_sep => ','``` as an option when e ### XLSX with Pick Lists ```ruby -options = { - # data sources are written to a `data` worksheet and may be referenced by - # multiple rows - "data_sources" => { - "food_types" => %w[Polenta Paella Papaya], - "countries" => %w[Canada Türkiye], - "cities" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]} - }, - "validations" => { - "favourite_food" => SpreadsheetExporter::ColumnValidation.new( - data_source: "food_types" - ), - "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( - data_source: "food_types" - ), - "country" => SpreadsheetExporter::ColumnValidation.new( - data_source: "countries" - ), - "city" => SpreadsheetExporter::ColumnValidation.new( - dependent_on: "country", - data_source: "cities" - ), - }, - "freeze_panes" => [1, 0] # number of rows and columns to freeze (only applies to XLSX) +# data sources are written to a `data` worksheet and may be referenced by +# multiple rows +data_sources = { + "food_types" => %w[Polenta Paella Papaya], + "countries" => %w[Canada Türkiye], + "cities" => {"Canada"=>["Sxwōxwiyám", "Toronto"], "Türkiye"=>["Eskişehir", "İzmir", "İstanbul"]} +} + +validations = { + "favourite_food" => SpreadsheetExporter::ColumnValidation.new( + data_source: "food_types" + ), + "yuckiest_food" => SpreadsheetExporter::ColumnValidation.new( + data_source: "food_types" + ), + "country" => SpreadsheetExporter::ColumnValidation.new( + data_source: "countries" + ), + "city" => SpreadsheetExporter::ColumnValidation.new( + dependent_on: "country", + data_source: "cities" + ), +} + +SpreadsheetExporter::XLSX.from_objects(array_of_objects, + data_sources: data_sources, + validations: validations, + freeze_panes: [1, 0] # number of rows and columns to freeze (only applies to XLSX) +) ``` From a32e6c41ba342ef73d7e55f49e1ea1026561289f Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 7 Sep 2022 12:42:43 -0700 Subject: [PATCH 23/26] Docs --- lib/spreadsheet_exporter/xlsx.rb | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/spreadsheet_exporter/xlsx.rb b/lib/spreadsheet_exporter/xlsx.rb index c49c959..cc9a886 100644 --- a/lib/spreadsheet_exporter/xlsx.rb +++ b/lib/spreadsheet_exporter/xlsx.rb @@ -38,7 +38,7 @@ def self.from_spreadsheet(spreadsheet, validations: {}, data_sources: {}, freeze added_data_sources = add_data_sources(workbook, header_format, data_sources) - add_worksheet_validation(workbook, worksheet, column_indexes, added_data_sources, header_format, options) + add_worksheet_validation(workbook, worksheet, column_indexes, added_data_sources, header_format, validations) add_frozen_panes(worksheet, freeze_panes) @@ -57,19 +57,22 @@ def self.add_frozen_panes(worksheet, freeze_panes) worksheet.freeze_panes(Integer(rows), Integer(cols)) end - # Write each data_source to the `data` worksheet and reference it with a named range - # - # `data_sources` is a hash in the format + # Write each data_source to the `data` worksheet and wrap it with a named range so + # we can easily reference it later. # + # `data_sources` is a hash in the format: # { 'data_source_id' => ['data', 'source', 'options'] } # - # For data sources dependent on the value in another column, the format is + # This will create a named range called `data_source_id`. # + # For data sources dependent on the value in another column, the format is # { 'data_source_id' => { - # 'other_col_val_1' => ['options', 'when', 'val is 1'], - # 'other_col_val_2' => ['options', 'when', 'val is 2'] + # 'other_col_val_1' => ['options', 'when', 'val is val_1'], + # 'other_col_val_2' => ['options', 'when', 'val is val_2'] # } # } + # + # This will create two named ranges: `data_source_id_val_1` and `data_source_id_val_2`. def self.add_data_sources(workbook, header_format, data_sources) return {} if data_sources.empty? @@ -83,13 +86,14 @@ def self.add_data_sources(workbook, header_format, data_sources) column_index = 0 data_sources.stringify_keys.each do |data_key, data_values| if data_values.is_a?(Hash) - # nested, conditional data structure + # this is a dependent data source data_values.each do |data_value, sub_values| sub_key = sanitize_defined_name("#{data_key}_#{data_value}") data_source_refs[sub_key] = add_data_source(workbook, data_sheet, sub_key, sub_values, column_index, header_format) column_index += 1 end else + # this is an independent data source data_source_refs[data_key] = add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) column_index += 1 end @@ -100,10 +104,9 @@ def self.add_data_sources(workbook, header_format, data_sources) # Write a data column to the `data` worksheet and define it as a named range # - # Returnd the named range's name + # Returns the named range's name def self.add_data_source(workbook, data_sheet, data_key, data_values, column_index, header_format) unless data_values.is_a?(Array) - debugger raise ArgumentError, "data_values should be an array (got #{data_values.inspect}" end @@ -119,7 +122,7 @@ def self.add_data_source(workbook, data_sheet, data_key, data_values, column_ind data_key end - def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sources, header_format, validations) + def self.add_worksheet_validation(workbook, worksheet, column_indexes, added_data_sources, header_format, validations) return if validations.empty? validations.each do |column_name, column_validation| @@ -135,7 +138,7 @@ def self.add_worksheet_validation(workbook, worksheet, column_indexes, data_sour parent_col = xl_col_to_name(parent_col_index, true) dependent_named_range(column_validation.data_source, parent_col) else - data_sources[column_validation.data_source] + added_data_sources[column_validation.data_source] end unless defined_name From fcff9f972da965d9e7f045e622bc2302cc7bf471 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 28 Sep 2022 10:55:03 -0700 Subject: [PATCH 24/26] Move test script --- README.md | 4 ++++ test_data.rb => test/fixtures.rb | 0 test.rb => test/test.rb | 14 ++------------ 3 files changed, 6 insertions(+), 12 deletions(-) rename test_data.rb => test/fixtures.rb (100%) rename test.rb => test/test.rb (81%) diff --git a/README.md b/README.md index 36ec598..926c575 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,7 @@ SpreadsheetExporter::XLSX.from_objects(array_of_objects, freeze_panes: [1, 0] # number of rows and columns to freeze (only applies to XLSX) ) ``` + +### Testing + +There isn't currently a test suite. You can generate a sample test XLSX file by running `test/test.rb`. diff --git a/test_data.rb b/test/fixtures.rb similarity index 100% rename from test_data.rb rename to test/fixtures.rb diff --git a/test.rb b/test/test.rb similarity index 81% rename from test.rb rename to test/test.rb index 570a491..7f90048 100755 --- a/test.rb +++ b/test/test.rb @@ -1,13 +1,6 @@ #!/usr/bin/env ruby -require_relative "./lib/spreadsheet_exporter" -require_relative "./test_data" -require "awesome_print" -require "debug" - -# http://support.microsoft.com/kb/211485 -# -# TODO: we should add a column for all the missing validations even if there is -# no data in it yet +require_relative "../lib/spreadsheet_exporter" +require_relative "./fixtures" data = [ {"name" => "Jim", "role" => "admin"}.merge(country_and_city), @@ -58,9 +51,6 @@ def self.human_attribute_name(att) end end -# debugger -# SpreadsheetExporter::CSV.from_objects(data, :humanize_headers_class => Humanizer) - options[:humanize_headers_class] = Humanizer options[:freeze_panes] = [1, 1] From f1fc22a86f167acbb06304e1a60917179cde906b Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 28 Sep 2022 10:59:26 -0700 Subject: [PATCH 25/26] Add docs to the macro --- macros.vb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/macros.vb b/macros.vb index b30114e..aafc784 100644 --- a/macros.vb +++ b/macros.vb @@ -1,3 +1,17 @@ +' This Visual Basic for Applications code can be added to the generated +' XLSX files to gain some data entry UX improvements. +' +' Specifically: +' - the ability to select multiple values for a single column with a data source +' - clearing dependent columns values when the column they are dependent_on changes +' +' Limitations: +' - only one parent col is supported and the dependent_on child columns must be +' all beside each other so they can be selected as a range +' +' You will need to change `ParentTypeCol`, `DependentTypeStartCol` and `DependentTypeEndCol` +' below before adding the code to your Excel file. + Option Explicit ' https://stackoverflow.com/a/48375276/559596 From e5e9a395997771477eea379bdf54e28e341ac5e7 Mon Sep 17 00:00:00 2001 From: Alex Dunae Date: Wed, 28 Sep 2022 11:03:06 -0700 Subject: [PATCH 26/26] Remove dead code --- test/fixtures.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/fixtures.rb b/test/fixtures.rb index ec9b5e6..c04afcf 100644 --- a/test/fixtures.rb +++ b/test/fixtures.rb @@ -10,7 +10,6 @@ def sample_country MEALS = %w[Omnivore Veg Vegan] -# COUNTRIES = %w[Canada Türkiye] COUNTRIES = %w[Canada Turkey] CONDITIONAL_CITIES = {