From 0e8a4180ed7b4f3aa927d85f9a95c506ffe8dad3 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Thu, 31 Oct 2024 11:29:33 -0700 Subject: [PATCH 1/8] Checkpoint as the llm writhes --- Gemfile | 5 ++- Gemfile.lock | 4 ++ _plugins/csv_block.rb | 93 +++++++++++++++++++++++++---------------- _spec/csv_block_spec.rb | 52 +++++++++++++++++++++++ 4 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 _spec/csv_block_spec.rb diff --git a/Gemfile b/Gemfile index f7e681a..3d96a1a 100644 --- a/Gemfile +++ b/Gemfile @@ -2,12 +2,15 @@ source "https://rubygems.org" gem "jekyll", "~> 4.3.4" gem "posix-spawn", "~> 0.3.9" -gem "ruby-graphviz" group :jekyll_plugins do gem "jekyll-mermaid", "~> 1.0.0" gem "jekyll-postfiles", "~> 3.1" gem "jekyll-seo-tag", :git => 'https://github.com/numist/jekyll-seo-tag.git', :branch => 'issue/461' + + # Local plugin dependencies + gem "ruby-graphviz" + gem "dentaku" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index c99eccb..265f2f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -23,6 +23,9 @@ GEM bigdecimal (3.1.8) colorator (1.1.0) concurrent-ruby (1.3.4) + dentaku (3.5.4) + bigdecimal + concurrent-ruby em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) @@ -105,6 +108,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + dentaku jekyll (~> 4.3.4) jekyll-mermaid (~> 1.0.0) jekyll-postfiles (~> 3.1) diff --git a/_plugins/csv_block.rb b/_plugins/csv_block.rb index 3c55efa..77a90b8 100644 --- a/_plugins/csv_block.rb +++ b/_plugins/csv_block.rb @@ -1,42 +1,19 @@ +require 'jekyll' +require 'liquid' require 'csv' - -# Usage: -# -# {% csv %} -# Name, Age, Occupation -# Alice, 30, Engineer -# Bob, 25, Designer -# Charlie, 35, Teacher -# {% endcsv %} -# -# {% csv header:false %} -# Alice, 30, Engineer -# Bob, 25, Designer -# Charlie, 35, Teacher -# {% endcsv %} -# -# {% csv header:true separator:tab %} -# Name Age Occupation -# Alice 30 Engineer -# Bob 25 Designer -# Charlie 35 Teacher -# {% endcsv %} -# +require 'dentaku' module Jekyll - class CSVBlock < Liquid::Block + class CSVBlock < ::Liquid::Block def initialize(tag_name, markup, tokens) super - # Parse options from the markup (e.g., header: false, separator: tab) @options = {} markup.strip.split.each do |option| key, value = option.split(':') - if key == 'header' - @options[key] = value == 'false' ? false : true - elsif key == 'separator' - @options[key] = value == 'tab' ? "\t" : ',' - end + @options[key] = (value == 'false' ? false : true) if key == 'header' + @options[key] = (value == 'tab' ? "\t" : ',') if key == 'separator' end + @calculator = Dentaku::Calculator.new end def render(context) @@ -44,8 +21,9 @@ def render(context) has_headers = @options.fetch('header', true) separator = @options.fetch('separator', ',') - # Parse CSV with or without headers and with the specified separator + # Parse CSV with headers and specified separator csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) + table_data = csv_data.map(&:to_hash) # Convert to an array of hashes for easy reference # Start generating HTML html = "\n" @@ -61,11 +39,13 @@ def render(context) # Generate data rows html << "\n" - csv_data.each do |row| + evaluated_cells = {} # Cache for evaluated cells + table_data.each_with_index do |row, row_index| html << "" - row = row.to_hash.values if has_headers - row.each do |value| - html << "" + row.each_with_index do |(header, value), col_index| + # Evaluate cell if it starts with '=' indicating a formula + evaluated_value = evaluate_formula(value, table_data, row_index, col_index, csv_data.headers, evaluated_cells) + html << "" end html << "\n" end @@ -73,6 +53,49 @@ def render(context) html end + + private + + def evaluate_formula(value, table_data, current_row, current_col, headers, evaluated_cells, visiting = Set.new) + return value unless value.is_a?(String) && value.start_with?('=') + + cell_key = [current_row, current_col] + return evaluated_cells[cell_key] if evaluated_cells.key?(cell_key) + + # Detect circular dependencies + if visiting.include?(cell_key) + return "Error: Circular Reference" + else + visiting.add(cell_key) + end + + formula = value[1..-1].strip # Remove '=' from start of formula + + # Replace cell references (like A1) with recursively evaluated values + formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| + row_index, col_index = parse_cell_reference(match, headers) + referenced_value = table_data.dig(row_index, headers[col_index]) + + # Recursively evaluate referenced cell if it's a formula + evaluate_formula(referenced_value, table_data, row_index, col_index, headers, evaluated_cells, visiting) + end + + # Evaluate using Dentaku + evaluated_result = @calculator.evaluate(formula_with_values) || value + evaluated_cells[cell_key] = evaluated_result + + visiting.delete(cell_key) + evaluated_result + rescue + value # Return original if evaluation fails + end + + def parse_cell_reference(ref, headers) + col_letter, row_number = ref.match(/([A-Z]+)(\d+)/i).captures + row_index = row_number.to_i - 1 + col_index = headers.index(col_letter.upcase) + [row_index, col_index] + end end end diff --git a/_spec/csv_block_spec.rb b/_spec/csv_block_spec.rb new file mode 100644 index 0000000..2b42184 --- /dev/null +++ b/_spec/csv_block_spec.rb @@ -0,0 +1,52 @@ +# spec/csv_block_spec.rb +require 'rspec' +require 'jekyll' +require 'liquid' +require_relative '../_plugins/csv_block' + +describe Jekyll::CSVBlock do + def render_csv_block(content, tag_options = "") + # Render the content within a {% csv %} block + Liquid::Template.parse("{% csv #{tag_options} %}#{content}{% endcsv %}") + .render({}) + end + + it 'renders a basic CSV table with headers' do + csv_content = "Name, Age, Occupation\nAlice, 30, Engineer\nBob, 25, Designer" + output = render_csv_block(csv_content, "header:true") + + expect(output).to include('
#{value}#{evaluated_value}
') + expect(output).to include('') + expect(output).to include('') + end + + it 'renders a table without headers' do + csv_content = "Alice, 30, Engineer\nBob, 25, Designer" + output = render_csv_block(csv_content, "header:false") + + expect(output).not_to include('') + end + + it 'supports tab-separated values' do + csv_content = "Name\tAge\tOccupation\nAlice\t30\tEngineer\nBob\t25\tDesigner" + output = render_csv_block(csv_content, "header:true separator:tab") + + expect(output).to include('') + expect(output).to include('') + end + + it 'evaluates basic formulas in cells' do + csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, =SUM(B2...C2)" + output = render_csv_block(csv_content, "header:true") + + expect(output).to include('') # 2 * 5 in Total column + end + + it 'evaluates formulas with references across rows and columns' do + csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2...C2)" + output = render_csv_block(csv_content, "header:true") + + expect(output).to include('') # 100 + 150 in Total column + end +end From 66cd0b1ab7bfddfc5e0692fd61e638d943d95960 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Thu, 31 Oct 2024 16:03:34 -0700 Subject: [PATCH 2/8] Checkpoint as tables render (equations all broken) --- _plugins/csv_block.rb | 109 ++++++++++++++++++++++------------------ _spec/csv_block_spec.rb | 46 ++++++++++++++++- 2 files changed, 105 insertions(+), 50 deletions(-) diff --git a/_plugins/csv_block.rb b/_plugins/csv_block.rb index 77a90b8..82dbfdb 100644 --- a/_plugins/csv_block.rb +++ b/_plugins/csv_block.rb @@ -1,3 +1,4 @@ +# _plugins/csv_block.rb require 'jekyll' require 'liquid' require 'csv' @@ -17,46 +18,52 @@ def initialize(tag_name, markup, tokens) end def render(context) - csv_text = super.strip - has_headers = @options.fetch('header', true) - separator = @options.fetch('separator', ',') - - # Parse CSV with headers and specified separator - csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) - table_data = csv_data.map(&:to_hash) # Convert to an array of hashes for easy reference - - # Start generating HTML - html = "
NameAlice') + expect(output).to include('AliceName3010250
\n" - - if has_headers - # Generate header row - html << "\n\n" - csv_data.headers.each do |header| - html << "" + begin + + csv_text = super.strip + has_headers = @options.fetch('header', true) + separator = @options.fetch('separator', ',') + + # Parse CSV with or without headers + csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) + table_data = csv_data.map { |row| has_headers ? row.to_hash.values : row } + headers = has_headers ? csv_data.headers : nil + + # Start generating HTML + html = "
#{header}
\n" + + if has_headers + # Generate header row + html << "\n\n" + headers.each do |header| + html << "" + end + html << "\n\n" end - html << "\n\n" - end - # Generate data rows - html << "\n" - evaluated_cells = {} # Cache for evaluated cells - table_data.each_with_index do |row, row_index| - html << "" - row.each_with_index do |(header, value), col_index| - # Evaluate cell if it starts with '=' indicating a formula - evaluated_value = evaluate_formula(value, table_data, row_index, col_index, csv_data.headers, evaluated_cells) - html << "" + # Generate data rows + html << "\n" + evaluated_cells = {} # Cache for evaluated cells + table_data.each_with_index do |row, row_index| + html << "" + row.each_with_index do |value, col_index| + evaluated_value = evaluate_formula(value, table_data, row_index, col_index, headers, evaluated_cells, has_headers) + html << "" + end + html << "\n" end - html << "\n" + html << "\n
#{header.strip}
#{evaluated_value}
#{evaluated_value}
\n" + + return html + rescue => e + return e.message end - html << "\n\n" - - html end private - def evaluate_formula(value, table_data, current_row, current_col, headers, evaluated_cells, visiting = Set.new) + # Evaluate cell if it starts with '=' indicating a formula + def evaluate_formula(value, table_data, current_row, current_col, headers, evaluated_cells, has_headers, visiting = Set.new) return value unless value.is_a?(String) && value.start_with?('=') cell_key = [current_row, current_col] @@ -64,36 +71,42 @@ def evaluate_formula(value, table_data, current_row, current_col, headers, evalu # Detect circular dependencies if visiting.include?(cell_key) - return "Error: Circular Reference" + return "Error: Circular Reference at cell #{cell_key}" else visiting.add(cell_key) end formula = value[1..-1].strip # Remove '=' from start of formula - # Replace cell references (like A1) with recursively evaluated values - formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| - row_index, col_index = parse_cell_reference(match, headers) - referenced_value = table_data.dig(row_index, headers[col_index]) - - # Recursively evaluate referenced cell if it's a formula - evaluate_formula(referenced_value, table_data, row_index, col_index, headers, evaluated_cells, visiting) + begin + # Replace cell references with values + formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| + row_index, col_index = parse_cell_reference(match, headers, has_headers) + referenced_value = table_data.dig(row_index, col_index) + return "Error: Out of Bounds reference #{match} at row #{row_index}, col #{col_index}" if referenced_value.nil? + + # Recursively evaluate referenced cell if it's a formula + evaluate_formula(referenced_value, table_data, row_index, col_index, headers, evaluated_cells, has_headers, visiting) + end + + # Evaluate using Dentaku + evaluated_result = @calculator.evaluate(formula_with_values) || value + rescue => e + return "Error: Formula Evaluation in cell #{cell_key} - #{e.message}" + ensure + visiting.delete(cell_key) end - # Evaluate using Dentaku - evaluated_result = @calculator.evaluate(formula_with_values) || value evaluated_cells[cell_key] = evaluated_result - - visiting.delete(cell_key) evaluated_result - rescue - value # Return original if evaluation fails end - def parse_cell_reference(ref, headers) + def parse_cell_reference(ref, headers, has_headers) col_letter, row_number = ref.match(/([A-Z]+)(\d+)/i).captures row_index = row_number.to_i - 1 - col_index = headers.index(col_letter.upcase) + col_index = has_headers ? headers.index(col_letter.upcase) : col_letter.ord - 'A'.ord + return "Error: Invalid Column Reference #{col_letter}" if col_index.nil? + [row_index, col_index] end end diff --git a/_spec/csv_block_spec.rb b/_spec/csv_block_spec.rb index 2b42184..d8c3995 100644 --- a/_spec/csv_block_spec.rb +++ b/_spec/csv_block_spec.rb @@ -11,7 +11,7 @@ def render_csv_block(content, tag_options = "") .render({}) end - it 'renders a basic CSV table with headers' do + it 'renders a table with headers' do csv_content = "Name, Age, Occupation\nAlice, 30, Engineer\nBob, 25, Designer" output = render_csv_block(csv_content, "header:true") @@ -28,9 +28,21 @@ def render_csv_block(content, tag_options = "") expect(output).to include('Alice') end + it 'renders tables with headers by default' do + csv_content = "Alice, 30, Engineer\nBob, 25, Designer" + output = render_csv_block(csv_content) + + csv_content = "Name, Age, Occupation\nAlice, 30, Engineer\nBob, 25, Designer" + output = render_csv_block(csv_content) + + expect(output).to include('') + expect(output).to include('') + expect(output).to include('') + end + it 'supports tab-separated values' do csv_content = "Name\tAge\tOccupation\nAlice\t30\tEngineer\nBob\t25\tDesigner" - output = render_csv_block(csv_content, "header:true separator:tab") + output = render_csv_block(csv_content, "separator:tab") expect(output).to include('') expect(output).to include('') @@ -49,4 +61,34 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') # 100 + 150 in Total column end + + it 'detects and reports circular dependencies' do + csv_content = "= B1, = A1" + output = render_csv_block(csv_content, "header:false") + expect(output).to include("Error: Circular Reference at cell [0, 1]") + end + + it 'detects and reports circular dependencies with headers' do + csv_content = "A, B\n= B2, = A2" + output = render_csv_block(csv_content, "header:true") + expect(output).to include("Error: Circular Reference at cell [0, 1]") + end + + it 'reports out-of-bounds references' do + csv_content = "A, B\n= B3, 20" + output = render_csv_block(csv_content, "header:true") + expect(output).to include("Error: Out of Bounds reference B3 at row 2, col 1") + end + + it 'reports invalid column references' do + csv_content = "A, B\n= C1, 20" + output = render_csv_block(csv_content, "header:true") + expect(output).to include("Error: Invalid Column Reference C") + end + + it 'reports formula syntax errors' do + csv_content = "A, B\n= 5 + *, 20" + output = render_csv_block(csv_content, "header:true") + expect(output).to include("Error: Formula Evaluation in cell [0, 0]") + end end From a26e9f0ba28e09f066d287d50afc893e0e58e1db Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Thu, 31 Oct 2024 17:40:22 -0700 Subject: [PATCH 3/8] correctly renders equations without cell references! --- _plugins/csv_block.rb | 49 ++++++++++++++++++++-------------- _spec/csv_block_spec.rb | 58 +++++++++++++++++++++++++++++++++-------- 2 files changed, 77 insertions(+), 30 deletions(-) diff --git a/_plugins/csv_block.rb b/_plugins/csv_block.rb index 82dbfdb..b0412c6 100644 --- a/_plugins/csv_block.rb +++ b/_plugins/csv_block.rb @@ -15,6 +15,7 @@ def initialize(tag_name, markup, tokens) @options[key] = (value == 'tab' ? "\t" : ',') if key == 'separator' end @calculator = Dentaku::Calculator.new + @evaluation_cache = {} end def render(context) @@ -26,8 +27,11 @@ def render(context) # Parse CSV with or without headers csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) - table_data = csv_data.map { |row| has_headers ? row.to_hash.values : row } - headers = has_headers ? csv_data.headers : nil + if has_headers + table_data = [csv_data.headers] + csv_data.map(&:to_hash).map(&:values) + else + table_data = csv_data + end # Start generating HTML html = "
NameAliceName30250
\n" @@ -35,7 +39,7 @@ def render(context) if has_headers # Generate header row html << "\n\n" - headers.each do |header| + table_data.first.each do |header| html << "" end html << "\n\n" @@ -43,11 +47,11 @@ def render(context) # Generate data rows html << "\n" - evaluated_cells = {} # Cache for evaluated cells table_data.each_with_index do |row, row_index| + next if row_index == 0 and has_headers html << "" row.each_with_index do |value, col_index| - evaluated_value = evaluate_formula(value, table_data, row_index, col_index, headers, evaluated_cells, has_headers) + evaluated_value = evaluate_formula(col_index, row_index, table_data) html << "" end html << "\n" @@ -63,11 +67,14 @@ def render(context) private # Evaluate cell if it starts with '=' indicating a formula - def evaluate_formula(value, table_data, current_row, current_col, headers, evaluated_cells, has_headers, visiting = Set.new) + def evaluate_formula(current_col, current_row, table_data, visiting = Set.new) + value = table_data[current_row][current_col].strip return value unless value.is_a?(String) && value.start_with?('=') - cell_key = [current_row, current_col] - return evaluated_cells[cell_key] if evaluated_cells.key?(cell_key) + cell_key = [current_col, current_row] + + # Return results out of cache, if possible + return @evaluation_cache[cell_key] if @evaluation_cache.key?(cell_key) # Detect circular dependencies if visiting.include?(cell_key) @@ -76,28 +83,32 @@ def evaluate_formula(value, table_data, current_row, current_col, headers, evalu visiting.add(cell_key) end - formula = value[1..-1].strip # Remove '=' from start of formula + # Remove '=' from start of formula + formula = value[1..-1].strip begin - # Replace cell references with values - formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| - row_index, col_index = parse_cell_reference(match, headers, has_headers) - referenced_value = table_data.dig(row_index, col_index) - return "Error: Out of Bounds reference #{match} at row #{row_index}, col #{col_index}" if referenced_value.nil? - - # Recursively evaluate referenced cell if it's a formula - evaluate_formula(referenced_value, table_data, row_index, col_index, headers, evaluated_cells, has_headers, visiting) - end + # Identify cell references and replace with values + # TODO: the below needs rewriting to match ranges—([A-Z]+)(\d+)(:([A-Z]+)(\d+))? + formula_with_values = formula + # formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| + # row_index, col_index = parse_cell_reference(match, headers, has_headers) + # referenced_value = table_data.dig(row_index, col_index) + # return "Error: Out of Bounds reference #{match} at row #{row_index}, col #{col_index}" if referenced_value.nil? + + # # Recursively evaluate referenced cell if it's a formula + # evaluate_formula(col_index, row_index, table_data, visiting) + # end # Evaluate using Dentaku evaluated_result = @calculator.evaluate(formula_with_values) || value rescue => e + puts e.backtrace return "Error: Formula Evaluation in cell #{cell_key} - #{e.message}" ensure visiting.delete(cell_key) end - evaluated_cells[cell_key] = evaluated_result + @evaluation_cache[cell_key] = evaluated_result evaluated_result end diff --git a/_spec/csv_block_spec.rb b/_spec/csv_block_spec.rb index d8c3995..3f688ef 100644 --- a/_spec/csv_block_spec.rb +++ b/_spec/csv_block_spec.rb @@ -28,6 +28,14 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end + it 'renders a single-row table with headers' do + csv_content = "Name, Age, Occupation" + output = render_csv_block(csv_content, "header:true") + + expect(output).to include('
#{header.strip}
#{evaluated_value}
Alice
') + expect(output).to include('') + end + it 'renders tables with headers by default' do csv_content = "Alice, 30, Engineer\nBob, 25, Designer" output = render_csv_block(csv_content) @@ -48,45 +56,73 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end - it 'evaluates basic formulas in cells' do - csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, =SUM(B2...C2)" + it 'evaluates formulas' do + csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, = 3 + 6" output = render_csv_block(csv_content, "header:true") + expect(output).to include('') # 2 * 5 in Total column + end + + it 'evaluates formulas with references across columns' do + csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, =SUM(B2:C2)" + output = render_csv_block(csv_content, "header:true") + + # TODO: lol llms are dumb. that's mul not sum expect(output).to include('') # 2 * 5 in Total column end - it 'evaluates formulas with references across rows and columns' do - csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2...C2)" + it 'evaluates formulas with references across rows' do + csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2:C2)" + output = render_csv_block(csv_content, "header:true") + + expect(output).to include('') # 100 + 150 in Total column + end + + # TODO: should it? + it 'evaluates formulas with rectangular references' do + csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2:C2)" output = render_csv_block(csv_content, "header:true") expect(output).to include('') # 100 + 150 in Total column end - it 'detects and reports circular dependencies' do - csv_content = "= B1, = A1" + it 'detects and reports self-referential equations' do + csv_content = "A, B\n= A2, 3" + output = render_csv_block(csv_content) + expect(output).to include("Error: Circular Reference at cell [0, 1]") + end + + it 'detects and reports self-referential equations without headers' do + csv_content = "A, B\n= A2, 3" output = render_csv_block(csv_content, "header:false") expect(output).to include("Error: Circular Reference at cell [0, 1]") end - it 'detects and reports circular dependencies with headers' do + it 'detects and reports evaluations with circular dependencies' do csv_content = "A, B\n= B2, = A2" - output = render_csv_block(csv_content, "header:true") + output = render_csv_block(csv_content) + expect(output).to include("Error: Circular Reference at cell [0, 1]") + end + + it 'detects and reports evaluations with circular dependencies without headers' do + csv_content = "A, B\n= B2, = A2" + output = render_csv_block(csv_content, "header:false") expect(output).to include("Error: Circular Reference at cell [0, 1]") end - it 'reports out-of-bounds references' do + it 'reports evaluations with out-of-bounds references' do csv_content = "A, B\n= B3, 20" output = render_csv_block(csv_content, "header:true") expect(output).to include("Error: Out of Bounds reference B3 at row 2, col 1") end - it 'reports invalid column references' do + it 'reports evaluations with invalid column references' do csv_content = "A, B\n= C1, 20" output = render_csv_block(csv_content, "header:true") expect(output).to include("Error: Invalid Column Reference C") end - it 'reports formula syntax errors' do + it 'reports evaluations with formula syntax errors' do csv_content = "A, B\n= 5 + *, 20" output = render_csv_block(csv_content, "header:true") expect(output).to include("Error: Formula Evaluation in cell [0, 0]") From 15b3baf0fabd57628b9fd33bc299f43752a3bbf9 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Sat, 2 Nov 2024 21:34:48 -0700 Subject: [PATCH 4/8] tests all pass, I guess that's it --- _plugins/csv_block.rb | 112 ++++++++++++++++++++++++++-------------- _spec/csv_block_spec.rb | 102 ++++++++++++++++++++++++------------ code/csv.md | 9 ++-- 3 files changed, 150 insertions(+), 73 deletions(-) diff --git a/_plugins/csv_block.rb b/_plugins/csv_block.rb index b0412c6..99334a0 100644 --- a/_plugins/csv_block.rb +++ b/_plugins/csv_block.rb @@ -39,8 +39,8 @@ def render(context) if has_headers # Generate header row html << "\n\n" - table_data.first.each do |header| - html << "" + table_data.first.each_with_index do |header, col_index| + html << "" end html << "\n\n" end @@ -51,7 +51,7 @@ def render(context) next if row_index == 0 and has_headers html << "" row.each_with_index do |value, col_index| - evaluated_value = evaluate_formula(col_index, row_index, table_data) + evaluated_value = evaluate_formula_at_index(col_index, row_index, table_data) html << "" end html << "\n" @@ -66,19 +66,46 @@ def render(context) private - # Evaluate cell if it starts with '=' indicating a formula - def evaluate_formula(current_col, current_row, table_data, visiting = Set.new) - value = table_data[current_row][current_col].strip - return value unless value.is_a?(String) && value.start_with?('=') + # Conversion functions between spreadsheet style ("val") and zero-based coordinates ("index") + def col_index_to_val(col_index) + (col_index + 'A'.ord).chr + end + def col_val_to_index(col_val) + if col_val.length == 1 + col_val.ord - 'A'.ord + else + base = 'Z'.ord - 'A'.ord + 1 + col_val.chars.reduce(0) { |result, char| result * base + char.ord - 'A'.ord + 1 } - 1 + end + end + def row_index_to_val(row_index) + row_index + 1 + end + def row_val_to_index(row_val) + row_val - 1 + end - cell_key = [current_col, current_row] + def evaluate_formula_at_index(col_index, row_index, table_data, visiting = Set.new) + begin + evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + rescue => e + e.message + end + end + + def evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + value = table_data[row_index][col_index].strip + return value unless value.is_a?(String) && value.start_with?('=') # Return results out of cache, if possible + cell_key = [col_index, row_index] return @evaluation_cache[cell_key] if @evaluation_cache.key?(cell_key) + # puts "evaluate_formula: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)}: #{value}" + # Detect circular dependencies if visiting.include?(cell_key) - return "Error: Circular Reference at cell #{cell_key}" + raise "Error: circular reference: #{visiting.to_a.map { |key| "#{col_index_to_val(key[0])}#{row_index_to_val(key[1])}" }.sort.join(', ')}" else visiting.add(cell_key) end @@ -86,40 +113,49 @@ def evaluate_formula(current_col, current_row, table_data, visiting = Set.new) # Remove '=' from start of formula formula = value[1..-1].strip - begin - # Identify cell references and replace with values - # TODO: the below needs rewriting to match ranges—([A-Z]+)(\d+)(:([A-Z]+)(\d+))? - formula_with_values = formula - # formula_with_values = formula.gsub(/([A-Z]+)(\d+)/i) do |match| - # row_index, col_index = parse_cell_reference(match, headers, has_headers) - # referenced_value = table_data.dig(row_index, col_index) - # return "Error: Out of Bounds reference #{match} at row #{row_index}, col #{col_index}" if referenced_value.nil? - - # # Recursively evaluate referenced cell if it's a formula - # evaluate_formula(col_index, row_index, table_data, visiting) - # end - - # Evaluate using Dentaku - evaluated_result = @calculator.evaluate(formula_with_values) || value - rescue => e - puts e.backtrace - return "Error: Formula Evaluation in cell #{cell_key} - #{e.message}" - ensure - visiting.delete(cell_key) + # Identify cell references and replace with values + formula_with_values = formula.gsub(/([A-Z]+)(\d+)(:([A-Z]+)(\d+))?/i) do |match| + if $3 + start_col, start_row, end_col, end_row = $1, $2.to_i, $4, $5.to_i + start_col_index = col_val_to_index(start_col) + start_row_index = row_val_to_index(start_row) + end_col_index = col_val_to_index(end_col) + end_row_index = row_val_to_index(end_row) + + # Check if the range is valid + if start_col_index.nil? || start_row_index.nil? || end_col_index.nil? || end_row_index.nil? \ + || start_col_index > end_col_index || start_row_index > end_row_index \ + || end_col_index >= table_data.first.length || end_row_index >= table_data.length + raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid range #{match}" + end + + # Evaluate each cell in the range + range_values = [] + (start_row_index..end_row_index).each do |row_index| + (start_col_index..end_col_index).each do |col_index| + range_values << evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + end + end + + # Join the range values with a comma + range_values.join(',') + else + ref_row_index = row_val_to_index($2.to_i) + ref_col_index = col_val_to_index($1) + referenced_value = table_data.dig(ref_row_index, ref_col_index) + raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid cell #{match}" if referenced_value.nil? + + # Recursively evaluate referenced cell if it's a formula + evaluate_formula_at_index_internal(ref_col_index, ref_row_index, table_data, visiting) + end end + # Evaluate using Dentaku + evaluated_result = @calculator.evaluate(formula_with_values) or raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} uses invalid formula: #{formula}" + @evaluation_cache[cell_key] = evaluated_result evaluated_result end - - def parse_cell_reference(ref, headers, has_headers) - col_letter, row_number = ref.match(/([A-Z]+)(\d+)/i).captures - row_index = row_number.to_i - 1 - col_index = has_headers ? headers.index(col_letter.upcase) : col_letter.ord - 'A'.ord - return "Error: Invalid Column Reference #{col_letter}" if col_index.nil? - - [row_index, col_index] - end end end diff --git a/_spec/csv_block_spec.rb b/_spec/csv_block_spec.rb index 3f688ef..25b2939 100644 --- a/_spec/csv_block_spec.rb +++ b/_spec/csv_block_spec.rb @@ -1,4 +1,4 @@ -# spec/csv_block_spec.rb +# _spec/csv_block_spec.rb require 'rspec' require 'jekyll' require 'liquid' @@ -10,7 +10,7 @@ def render_csv_block(content, tag_options = "") Liquid::Template.parse("{% csv #{tag_options} %}#{content}{% endcsv %}") .render({}) end - + it 'renders a table with headers' do csv_content = "Name, Age, Occupation\nAlice, 30, Engineer\nBob, 25, Designer" output = render_csv_block(csv_content, "header:true") @@ -28,14 +28,6 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end - it 'renders a single-row table with headers' do - csv_content = "Name, Age, Occupation" - output = render_csv_block(csv_content, "header:true") - - expect(output).to include('
Name30910250250
#{header.strip}#{evaluate_formula_at_index(col_index, 0, table_data)}
#{evaluated_value}
Alice
') - expect(output).to include('') - end - it 'renders tables with headers by default' do csv_content = "Alice, 30, Engineer\nBob, 25, Designer" output = render_csv_block(csv_content) @@ -48,6 +40,14 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end + it 'renders a single-row table with headers' do + csv_content = "Name, Age, Occupation" + output = render_csv_block(csv_content) + + expect(output).to include('
NameAlice
') + expect(output).to include('') + end + it 'supports tab-separated values' do csv_content = "Name\tAge\tOccupation\nAlice\t30\tEngineer\nBob\t25\tDesigner" output = render_csv_block(csv_content, "separator:tab") @@ -58,73 +58,111 @@ def render_csv_block(content, tag_options = "") it 'evaluates formulas' do csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, = 3 + 6" - output = render_csv_block(csv_content, "header:true") + output = render_csv_block(csv_content) expect(output).to include('') # 2 * 5 in Total column end + it 'evaluates formulas in headers' do + csv_content = "Item, Quantity, Price, =6*9\nWidget, 2, 5, 54" + output = render_csv_block(csv_content) + + expect(output).to include('') # 2 * 5 in Total column + end + + it 'evaluates formulas with references' do + csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, =B2+1" + output = render_csv_block(csv_content) + + expect(output).to include('') + end + + it 'evaluates formulas with references to columns > 26' do + csv_content = "=AD1 + 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29" + output = render_csv_block(csv_content, "header:false") + + expect(output).to include('') + end + + it 'evaluates formulas recursively' do + csv_content = "Item, Quantity, Price, Total\nWidget, = C2 + 1, 5, =B2+1" + output = render_csv_block(csv_content) + + expect(output).to include('') + end + it 'evaluates formulas with references across columns' do csv_content = "Item, Quantity, Price, Total\nWidget, 2, 5, =SUM(B2:C2)" - output = render_csv_block(csv_content, "header:true") + output = render_csv_block(csv_content) - # TODO: lol llms are dumb. that's mul not sum - expect(output).to include('') # 2 * 5 in Total column + expect(output).to include('') end it 'evaluates formulas with references across rows' do - csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2:C2)" - output = render_csv_block(csv_content, "header:true") + csv_content = "100\n150\n=SUM(A1:A2)" + output = render_csv_block(csv_content, "header:false") - expect(output).to include('') # 100 + 150 in Total column + expect(output).to include('') end - # TODO: should it? it 'evaluates formulas with rectangular references' do - csv_content = "Product, Q1, Q2, Total\nGadget, 100, 150, =SUM(B2:C2)" - output = render_csv_block(csv_content, "header:true") + csv_content = "1, 10, =SUM(A1:B1)\n100, 1000, =SUM(A2:B2)\n=SUM(A1:A2), =SUM(B1:B2), =SUM(A1:B2)" + output = render_csv_block(csv_content, "header:false") - expect(output).to include('') # 100 + 150 in Total column + expect(output).to include('') + expect(output).to include('') + expect(output).to include('') + expect(output).to include('') + expect(output).to include('') end +# TODO: test backwards ranges + it 'detects and reports self-referential equations' do csv_content = "A, B\n= A2, 3" output = render_csv_block(csv_content) - expect(output).to include("Error: Circular Reference at cell [0, 1]") + expect(output).to include("Error: circular reference: A2") end it 'detects and reports self-referential equations without headers' do csv_content = "A, B\n= A2, 3" output = render_csv_block(csv_content, "header:false") - expect(output).to include("Error: Circular Reference at cell [0, 1]") + expect(output).to include("Error: circular reference: A2") end it 'detects and reports evaluations with circular dependencies' do csv_content = "A, B\n= B2, = A2" output = render_csv_block(csv_content) - expect(output).to include("Error: Circular Reference at cell [0, 1]") + expect(output).to include("Error: circular reference: A2, B2") end it 'detects and reports evaluations with circular dependencies without headers' do csv_content = "A, B\n= B2, = A2" output = render_csv_block(csv_content, "header:false") - expect(output).to include("Error: Circular Reference at cell [0, 1]") + expect(output).to include("Error: circular reference: A2, B2") end - it 'reports evaluations with out-of-bounds references' do + it 'reports evaluations with invalid row references' do csv_content = "A, B\n= B3, 20" - output = render_csv_block(csv_content, "header:true") - expect(output).to include("Error: Out of Bounds reference B3 at row 2, col 1") + output = render_csv_block(csv_content) + expect(output).to include("Error: A2 references invalid cell B3") end it 'reports evaluations with invalid column references' do csv_content = "A, B\n= C1, 20" - output = render_csv_block(csv_content, "header:true") - expect(output).to include("Error: Invalid Column Reference C") + output = render_csv_block(csv_content) + expect(output).to include("Error: A2 references invalid cell C1") + end + + it 'reports evaluations with invalid range references' do + csv_content = "A, B\n= A1:C3, 20" + output = render_csv_block(csv_content) + expect(output).to include("Error: A2 references invalid range A1:C3") end it 'reports evaluations with formula syntax errors' do csv_content = "A, B\n= 5 + *, 20" - output = render_csv_block(csv_content, "header:true") - expect(output).to include("Error: Formula Evaluation in cell [0, 0]") + output = render_csv_block(csv_content) + expect(output).to include("Error: A2 uses invalid formula: 5 + *") end end diff --git a/code/csv.md b/code/csv.md index 72d4dc3..5891463 100644 --- a/code/csv.md +++ b/code/csv.md @@ -5,12 +5,11 @@ description: "For when the Markdown syntax is too annoying to work with." published_at: Wed Oct 30 20:54:57 PDT 2024 --- -Not that I'm using tables anywhere on this site, but I did some tabular-shaped stuff at work recently and it really made me appreciate the ubiquity of CSV data and tooling that understands me. Which immediately made me cranky about Markdown's table syntax, which is annoying to edit even among people who can remember its syntax. +Not that I'm using tables anywhere on this site, but I did some tabular-shaped stuff at work recently and it really made me appreciate the ubiquity of CSV data and tooling that understands me. Which immediately made me cranky about Markdown's table syntax, which is annoying to edit even among people who can remember it in the first place. -So, for the sake of easing my soul by writing a gift to my future self: +So, here's a basic plugin that turns CSV/TSV into a `
Name954330710725025025011101101011001111
`: ``` ruby -# _plugins/csv_block.rb require 'csv' module Jekyll @@ -114,3 +113,7 @@ Alice 30 Engineer Bob 25 Designer Charlie 35 Teacher {% endcsv %} + +And if you're keen to write equations (and don't mind adding `dentaku` to your dependencies), [here's](https://github.com/numist/numi.st/blob/{{ site.git_head | default: "main" }}/_plugins/csv_block.rb) a version[^tests] that supports the common `=SUM(A1:A15)` style. + +[^tests]: [With tests!](https://github.com/numist/numi.st/blob/{{ site.git_head | default: "main" }}/_spec/csv_block_spec.rb) From 09d26038452d88cd5bc62294aa50e82e24e27669 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Sat, 2 Nov 2024 21:57:23 -0700 Subject: [PATCH 5/8] Another round of testing and cleanup --- _plugins/csv_block.rb | 48 +++++++++++++++++++++++------------------ _spec/csv_block_spec.rb | 36 ++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/_plugins/csv_block.rb b/_plugins/csv_block.rb index 99334a0..598f288 100644 --- a/_plugins/csv_block.rb +++ b/_plugins/csv_block.rb @@ -25,7 +25,6 @@ def render(context) has_headers = @options.fetch('header', true) separator = @options.fetch('separator', ',') - # Parse CSV with or without headers csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) if has_headers table_data = [csv_data.headers] + csv_data.map(&:to_hash).map(&:values) @@ -33,11 +32,9 @@ def render(context) table_data = csv_data end - # Start generating HTML html = "
\n" if has_headers - # Generate header row html << "\n\n" table_data.first.each_with_index do |header, col_index| html << "" @@ -45,7 +42,6 @@ def render(context) html << "\n\n" end - # Generate data rows html << "\n" table_data.each_with_index do |row, row_index| next if row_index == 0 and has_headers @@ -66,10 +62,19 @@ def render(context) private - # Conversion functions between spreadsheet style ("val") and zero-based coordinates ("index") + # + # Conversion functions between spreadsheet style (B3, "val") and zero-based coordinates ([2, 1], "index") + # + def col_index_to_val(col_index) + if col_index < 26 (col_index + 'A'.ord).chr + else + quotient, remainder = col_index.divmod(26) + col_index_to_val(quotient - 1) + col_index_to_val(remainder) + end end + def col_val_to_index(col_val) if col_val.length == 1 col_val.ord - 'A'.ord @@ -78,40 +83,47 @@ def col_val_to_index(col_val) col_val.chars.reduce(0) { |result, char| result * base + char.ord - 'A'.ord + 1 } - 1 end end + def row_index_to_val(row_index) row_index + 1 end + def row_val_to_index(row_val) row_val - 1 end - def evaluate_formula_at_index(col_index, row_index, table_data, visiting = Set.new) + # Convert zero-based coordinates to spreadsheet style + def indices_to_val(col_index, row_index) + "#{col_index_to_val(col_index)}#{row_index_to_val(row_index)}" + end + + # Evaluate a formula at a given cell index + # This wrapper function catches exceptions thrown from the actual implementation, converting them into error + # messages for display in the rendered HTML. This is necessary because Liquid does not support exceptions in + # custom tags, swallowing the error details. + def evaluate_formula_at_index(col_index, row_index, table_data) begin - evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + evaluate_formula_at_index_internal(col_index, row_index, table_data) rescue => e e.message end end - def evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + # Implementation of formula evaluation. + def evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting = Set.new) value = table_data[row_index][col_index].strip return value unless value.is_a?(String) && value.start_with?('=') - # Return results out of cache, if possible cell_key = [col_index, row_index] return @evaluation_cache[cell_key] if @evaluation_cache.key?(cell_key) - # puts "evaluate_formula: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)}: #{value}" - - # Detect circular dependencies if visiting.include?(cell_key) raise "Error: circular reference: #{visiting.to_a.map { |key| "#{col_index_to_val(key[0])}#{row_index_to_val(key[1])}" }.sort.join(', ')}" else visiting.add(cell_key) end - # Remove '=' from start of formula - formula = value[1..-1].strip + formula = value[1..-1].strip # Removes '=' from start of formula # Identify cell references and replace with values formula_with_values = formula.gsub(/([A-Z]+)(\d+)(:([A-Z]+)(\d+))?/i) do |match| @@ -122,22 +134,18 @@ def evaluate_formula_at_index_internal(col_index, row_index, table_data, visitin end_col_index = col_val_to_index(end_col) end_row_index = row_val_to_index(end_row) - # Check if the range is valid if start_col_index.nil? || start_row_index.nil? || end_col_index.nil? || end_row_index.nil? \ || start_col_index > end_col_index || start_row_index > end_row_index \ || end_col_index >= table_data.first.length || end_row_index >= table_data.length raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid range #{match}" end - # Evaluate each cell in the range range_values = [] (start_row_index..end_row_index).each do |row_index| (start_col_index..end_col_index).each do |col_index| - range_values << evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) + range_values << evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting) end end - - # Join the range values with a comma range_values.join(',') else ref_row_index = row_val_to_index($2.to_i) @@ -145,12 +153,10 @@ def evaluate_formula_at_index_internal(col_index, row_index, table_data, visitin referenced_value = table_data.dig(ref_row_index, ref_col_index) raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid cell #{match}" if referenced_value.nil? - # Recursively evaluate referenced cell if it's a formula evaluate_formula_at_index_internal(ref_col_index, ref_row_index, table_data, visiting) end end - # Evaluate using Dentaku evaluated_result = @calculator.evaluate(formula_with_values) or raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} uses invalid formula: #{formula}" @evaluation_cache[cell_key] = evaluated_result diff --git a/_spec/csv_block_spec.rb b/_spec/csv_block_spec.rb index 25b2939..27d21e6 100644 --- a/_spec/csv_block_spec.rb +++ b/_spec/csv_block_spec.rb @@ -77,6 +77,13 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end + it 'evaluates formulas with multiple references to the same cell' do + csv_content = "Item, Quantity, Price, Total\nWidget, =2, 5, =B2+B2" + output = render_csv_block(csv_content) + + expect(output).to include('') + end + it 'evaluates formulas with references to columns > 26' do csv_content = "=AD1 + 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29" output = render_csv_block(csv_content, "header:false") @@ -116,27 +123,39 @@ def render_csv_block(content, tag_options = "") expect(output).to include('') end -# TODO: test backwards ranges + it 'reports invalid row ranges' do + csv_content = "A, B\n= A2:A1, 20" + output = render_csv_block(csv_content) + + expect(output).to include("Error: A2 references invalid range A2:A1") + end - it 'detects and reports self-referential equations' do + it 'reports invalid column ranges' do + csv_content = "A, B\n= B1:A1, 20" + output = render_csv_block(csv_content) + + expect(output).to include("Error: A2 references invalid range B1:A1") + end + + it 'reports self-referential equations' do csv_content = "A, B\n= A2, 3" output = render_csv_block(csv_content) expect(output).to include("Error: circular reference: A2") end - it 'detects and reports self-referential equations without headers' do + it 'reports self-referential equations without headers' do csv_content = "A, B\n= A2, 3" output = render_csv_block(csv_content, "header:false") expect(output).to include("Error: circular reference: A2") end - it 'detects and reports evaluations with circular dependencies' do + it 'reports evaluations with circular dependencies' do csv_content = "A, B\n= B2, = A2" output = render_csv_block(csv_content) expect(output).to include("Error: circular reference: A2, B2") end - it 'detects and reports evaluations with circular dependencies without headers' do + it 'reports evaluations with circular dependencies without headers' do csv_content = "A, B\n= B2, = A2" output = render_csv_block(csv_content, "header:false") expect(output).to include("Error: circular reference: A2, B2") @@ -165,4 +184,11 @@ def render_csv_block(content, tag_options = "") output = render_csv_block(csv_content) expect(output).to include("Error: A2 uses invalid formula: 5 + *") end + + it 'reports circular references to columns > 26' do + csv_content = "0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, =AD1" + output = render_csv_block(csv_content, "header:false") + + expect(output).to include("Error: circular reference: AD1") + end end From 02ae0aa99b67c32e1895829290096c45f196229f Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Sat, 2 Nov 2024 22:05:59 -0700 Subject: [PATCH 6/8] Update the post for the new, fully scope crept plugin --- code/csv.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/code/csv.md b/code/csv.md index 5891463..7ed6117 100644 --- a/code/csv.md +++ b/code/csv.md @@ -5,11 +5,12 @@ description: "For when the Markdown syntax is too annoying to work with." published_at: Wed Oct 30 20:54:57 PDT 2024 --- -Not that I'm using tables anywhere on this site, but I did some tabular-shaped stuff at work recently and it really made me appreciate the ubiquity of CSV data and tooling that understands me. Which immediately made me cranky about Markdown's table syntax, which is annoying to edit even among people who can remember it in the first place. +I engaged in some tabular shenanigans at work recently and it really made me appreciate CSV. This, of course, made me cranky about Markdown's table syntax, which is annoying to edit even among people who can remember it in the first place. -So, here's a basic plugin that turns CSV/TSV into a `
#{evaluate_formula_at_index(col_index, 0, table_data)}
341111
`: +So here's a Jekyll plugin that takes CSV/TSV and emits a `
`: ``` ruby +# _plugins/csv_block.rb require 'csv' module Jekyll @@ -68,7 +69,7 @@ end Liquid::Template.register_tag('csv', Jekyll::CSVBlock) ``` -Usage: +## Usage: ``` liquid {% raw %}{% csv %} @@ -92,7 +93,7 @@ Charlie 35 Teacher {% endcsv %}{% endraw %} ``` -Output: +### Output: {% csv %} Name, Age, Occupation @@ -114,6 +115,8 @@ Bob 25 Designer Charlie 35 Teacher {% endcsv %} -And if you're keen to write equations (and don't mind adding `dentaku` to your dependencies), [here's](https://github.com/numist/numi.st/blob/{{ site.git_head | default: "main" }}/_plugins/csv_block.rb) a version[^tests] that supports the common `=SUM(A1:A15)` style. +## But wait, there's more! + +If you're keen to write equations (and don't mind adding `dentaku` to your dependencies), [here's](https://github.com/numist/numi.st/blob/{{ site.git_head | default: "main" }}/_plugins/csv_block.rb) a version[^tests] that supports the common `=SUM(A1:A15)` style. [^tests]: [With tests!](https://github.com/numist/numi.st/blob/{{ site.git_head | default: "main" }}/_spec/csv_block_spec.rb) From 760b00c59f38cb41e509668d416fbd9e91341b82 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Sat, 2 Nov 2024 22:11:46 -0700 Subject: [PATCH 7/8] Remove reference to deleted post --- _posts/2022-02-17-feedyour.email/2022-02-17-feedyour.email.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_posts/2022-02-17-feedyour.email/2022-02-17-feedyour.email.md b/_posts/2022-02-17-feedyour.email/2022-02-17-feedyour.email.md index 9db1299..fbb28de 100644 --- a/_posts/2022-02-17-feedyour.email/2022-02-17-feedyour.email.md +++ b/_posts/2022-02-17-feedyour.email/2022-02-17-feedyour.email.md @@ -19,7 +19,7 @@ The next day he had a prototype working[^brooks], and the day after that it was The next week or two brought more features, like [support for emails bounced into feeds](https://github.com/indirect/feedyour.email/pull/85) using mail rules and [feed-specific favicons](https://github.com/indirect/feedyour.email/issues/23). It's really good. -As a side benefit, this exercise also gave me some exposure to modern web development[^tmbo], which was fun and led indirectly to [this blog](/post/1969/new-blog/). +As a side benefit, this exercise also gave me some exposure to modern web development[^tmbo], which was fun and led indirectly to this blog. (P.S. André [wrote something about feedyour.email](https://andre.arko.net/2022/02/17/feedyouremail/) too) From 17c144a43dd61df1847a84344f8fb47addc45ed9 Mon Sep 17 00:00:00 2001 From: Scott Perry Date: Sat, 2 Nov 2024 22:15:54 -0700 Subject: [PATCH 8/8] Minimize the example plugin a bit more --- code/csv.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/code/csv.md b/code/csv.md index 7ed6117..02c1cc5 100644 --- a/code/csv.md +++ b/code/csv.md @@ -17,7 +17,6 @@ module Jekyll class CSVBlock < Liquid::Block def initialize(tag_name, markup, tokens) super - # Parse options from the markup (e.g., header: false, separator: tab) @options = {} markup.strip.split.each do |option| key, value = option.split(':') @@ -34,22 +33,16 @@ module Jekyll has_headers = @options.fetch('header', true) separator = @options.fetch('separator', ',') - # Parse CSV with or without headers and with the specified separator csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator) - # Start generating HTML html = "
\n" - if has_headers - # Generate header row html << "\n\n" csv_data.headers.each do |header| html << "" end html << "\n\n" end - - # Generate data rows html << "\n" csv_data.each do |row| html << ""
#{header}