From 3dff0e3f2f92008f5532cf2511d814dc7d9e13c5 Mon Sep 17 00:00:00 2001 From: Mark Vletter Date: Tue, 18 Aug 2026 09:48:50 +0200 Subject: [PATCH 1/2] Add Notion page table support --- lib/notion/notion.rb | 38 ++++ lib/notion/views/full.html.erb | 22 ++- lib/notion/views/half_horizontal.html.erb | 22 ++- lib/notion/views/half_vertical.html.erb | 24 ++- lib/notion/views/quadrant.html.erb | 22 ++- lib/notion/views/shared/_page_block.html.erb | 4 +- lib/notion/views/shared/_table.html.erb | 192 +++++++++++++++++++ 7 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 lib/notion/views/shared/_table.html.erb diff --git a/lib/notion/notion.rb b/lib/notion/notion.rb index 7cdd20d..c804f11 100644 --- a/lib/notion/notion.rb +++ b/lib/notion/notion.rb @@ -322,6 +322,8 @@ def format_blocks(blocks) block_type = block["type"] block_data = block[block_type] || {} + next format_table_block(block, block_data) if block_type == "table" + { type: block_type, text: extract_block_text(block_data, block), @@ -333,6 +335,42 @@ def format_blocks(blocks) end end + def format_table_block(block, block_data) + rows = fetch_table_rows(block["id"]).map do |row| + Array(row.dig("table_row", "cells")).map { |cell| extract_rich_text(cell) } + end + + table_width = block_data["table_width"].to_i + table_width = rows.map(&:length).max.to_i if table_width.zero? + + { + type: "table", + table_width: table_width, + has_column_header: block_data["has_column_header"] == true, + has_row_header: block_data["has_row_header"] == true, + rows: normalize_table_rows(rows, table_width) + } + end + + def fetch_table_rows(table_id) + response = api_client.get_page_blocks(table_id, page_size: 100) + Array(response["results"]).select { |block| block["type"] == "table_row" } + end + + def normalize_table_rows(rows, table_width) + return [] if table_width.zero? + + rows.map do |row| + normalized_row = row.first(table_width) + normalized_row.fill("", normalized_row.length...table_width) + end + end + + def extract_rich_text(rich_text) + text = Array(rich_text).map { |fragment| fragment["plain_text"] || fragment.dig("text", "content") }.join + strip_emojis(text) + end + def extract_block_text(block_data, block) caption = block_data.dig("caption", 0, "text", "content").presence diff --git a/lib/notion/views/full.html.erb b/lib/notion/views/full.html.erb index df5d98c..d87ef49 100644 --- a/lib/notion/views/full.html.erb +++ b/lib/notion/views/full.html.erb @@ -22,22 +22,30 @@ <% end %> <% elsif display_type == "page" %> -
- <% if items && items[:blocks] && items[:blocks].any? %> -
-
+ <% if items && items[:blocks] && items[:blocks].any? %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> +
"> + <% if has_table %> +
+
+ <% else %> +
+
+ <% end %> <% items[:blocks].each_with_index do |block, idx| %> <%= render 'plugins/notion/shared/page_block', block: block, index: idx, current_layout: "full", image_height: image_height %> <% end %>
- <% else %> +
+ <% else %> +
<%= render 'plugins/notion/shared/error_section', title_class: "title", title_text: "No Content Available", description_text: "Check your Notion configuration and try again" %> - <% end %> -
+
+ <% end %> <% end %> <%= render partial: 'plugins/notion/shared/title_bar', locals: { instance_name: instance_name } %> diff --git a/lib/notion/views/half_horizontal.html.erb b/lib/notion/views/half_horizontal.html.erb index 8261644..f95e4b5 100644 --- a/lib/notion/views/half_horizontal.html.erb +++ b/lib/notion/views/half_horizontal.html.erb @@ -22,22 +22,30 @@ <% end %>
<% elsif display_type == "page" %> -
- <% if items && items[:blocks] && items[:blocks].any? %> -
-
+ <% if items && items[:blocks] && items[:blocks].any? %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> +
"> + <% if has_table %> +
+
+ <% else %> +
+
+ <% end %> <% items[:blocks].each_with_index do |block, idx| %> <%= render 'plugins/notion/shared/page_block', block: block, index: idx, current_layout: "half_horizontal", image_height: image_height %> <% end %>
- <% else %> +
+ <% else %> +
<%= render 'plugins/notion/shared/error_section', title_class: "title title--small", title_text: "No Content", description_text: "Check your Notion configuration" %> - <% end %> -
+
+ <% end %> <% end %> <%= render partial: 'plugins/notion/shared/title_bar', locals: { instance_name: instance_name } %> diff --git a/lib/notion/views/half_vertical.html.erb b/lib/notion/views/half_vertical.html.erb index 0dbdbbe..f4de6ca 100644 --- a/lib/notion/views/half_vertical.html.erb +++ b/lib/notion/views/half_vertical.html.erb @@ -21,23 +21,31 @@ description_text: "Check your Notion configuration" %> <% end %>
-<% elsif display_type == "page" %> -
- <% if items && items[:blocks] && items[:blocks].any? %> -
-
+ <% elsif display_type == "page" %> + <% if items && items[:blocks] && items[:blocks].any? %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> +
"> + <% if has_table %> +
+
+ <% else %> +
+
+ <% end %> <% items[:blocks].each_with_index do |block, idx| %> <%= render 'plugins/notion/shared/page_block', block: block, index: idx, current_layout: "half_vertical", image_height: image_height %> <% end %>
- <% else %> +
+ <% else %> +
<%= render 'plugins/notion/shared/error_section', title_class: "title title--small", title_text: "No Content", description_text: "Check your Notion configuration" %> - <% end %> -
+
+ <% end %> <% end %> <%= render partial: 'plugins/notion/shared/title_bar', locals: { instance_name: instance_name } %> diff --git a/lib/notion/views/quadrant.html.erb b/lib/notion/views/quadrant.html.erb index 577621c..49a8705 100644 --- a/lib/notion/views/quadrant.html.erb +++ b/lib/notion/views/quadrant.html.erb @@ -22,22 +22,30 @@ <% end %>
<% elsif display_type == "page" %> -
- <% if items && items[:blocks] && items[:blocks].any? %> -
-
+ <% if items && items[:blocks] && items[:blocks].any? %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> +
"> + <% if has_table %> +
+
+ <% else %> +
+
+ <% end %> <% items[:blocks].each_with_index do |block, idx| %> <%= render 'plugins/notion/shared/page_block', block: block, index: idx, current_layout: "quadrant", image_height: image_height %> <% end %>
- <% else %> +
+ <% else %> +
<%= render 'plugins/notion/shared/error_section', title_class: "title title--small", title_text: "No Content", description_text: "Check config" %> - <% end %> -
+
+ <% end %> <% end %> <%= render partial: 'plugins/notion/shared/title_bar', locals: { instance_name: instance_name } %> diff --git a/lib/notion/views/shared/_page_block.html.erb b/lib/notion/views/shared/_page_block.html.erb index ed4df8c..899f915 100644 --- a/lib/notion/views/shared/_page_block.html.erb +++ b/lib/notion/views/shared/_page_block.html.erb @@ -1,6 +1,8 @@ <% if block[:type] == "divider" %>
-<% elsif ["table_of_contents", "table", "unsupported", "synced_block", "breadcrumb", "column_list"].include?(block[:type]) %> +<% elsif block[:type] == "table" %> + <%= render 'plugins/notion/shared/table', table: block, current_layout: current_layout %> +<% elsif ["table_of_contents", "unsupported", "synced_block", "breadcrumb", "column_list"].include?(block[:type]) %> <% return %> <% else %>
diff --git a/lib/notion/views/shared/_table.html.erb b/lib/notion/views/shared/_table.html.erb new file mode 100644 index 0000000..548d85c --- /dev/null +++ b/lib/notion/views/shared/_table.html.erb @@ -0,0 +1,192 @@ +<% + layout_limits = { + "full" => { rows: 12, columns: 8, lines: 3 }, + "half_horizontal" => { rows: 6, columns: 8, lines: 2 }, + "half_vertical" => { rows: 10, columns: 4, lines: 3 }, + "quadrant" => { rows: 6, columns: 4, lines: 2 } + } + limits = layout_limits.fetch(current_layout, layout_limits["full"]) + + source_rows = Array(table[:rows]).map { |row| Array(row) } + source_column_count = table[:table_width].to_i + source_column_count = source_rows.map(&:length).max.to_i if source_column_count.zero? + + hidden_column_count = source_column_count > limits[:columns] ? source_column_count - limits[:columns] + 1 : 0 + visible_source_columns = hidden_column_count.positive? ? limits[:columns] - 1 : source_column_count + displayed_column_count = [source_column_count, limits[:columns]].min + + hidden_row_count = source_rows.length > limits[:rows] ? source_rows.length - limits[:rows] + 1 : 0 + visible_source_rows = hidden_row_count.positive? ? limits[:rows] - 1 : source_rows.length + + rows = source_rows.first(visible_source_rows).map.with_index do |row, row_index| + cells = row.first(visible_source_columns) + cells.fill("", cells.length...visible_source_columns) + cells << (row_index.zero? ? "+#{hidden_column_count} cols" : "…") if hidden_column_count.positive? + cells + end + + if hidden_row_count.positive? + summary_row = Array.new(displayed_column_count, "…") + summary_row[0] = "+#{hidden_row_count} rows" + rows << summary_row + end + + dense = rows.length > 8 || displayed_column_count > 6 + line_limit = dense ? [limits[:lines], 2].min : limits[:lines] +%> + +<% if rows.any? && displayed_column_count.positive? %> + + + <% has_column_header = table[:has_column_header] && visible_source_rows.positive? %> +
+ " + aria-rowcount="<%= source_rows.length %>" + aria-colcount="<%= source_column_count %>" + style="--notion-table-columns: <%= displayed_column_count %>; --notion-table-rows: <%= rows.length %>;"> + <% rows.each_with_index do |row, row_index| %> + <% if has_column_header && row_index.zero? %><% elsif row_index == (has_column_header ? 1 : 0) %><% end %> + + <% row.each_with_index do |cell, column_index| %> + <% summary = (hidden_row_count.positive? && row_index == rows.length - 1) || (hidden_column_count.positive? && column_index == row.length - 1) %> + <% header = (has_column_header && row_index.zero?) || (table[:has_row_header] && column_index.zero?) %> + <% important = !summary && cell.to_s.lines.any? { |line| line.lstrip.start_with?("!") } %> + <% cell_classes = ["notion-table__cell"] %> + <% cell_classes << "notion-table__cell--header" if header %> + <% cell_classes << "notion-table__cell--summary" if summary %> + <% cell_classes << "notion-table__cell--important" if important %> + <% tag_name = header ? "th" : "td" %> + <<%= tag_name %> class="<%= cell_classes.join(" ") %>"<% if header %> scope="<%= has_column_header && row_index.zero? ? "col" : "row" %>"<% end %>> + <%= cell %> + > + <% end %> + + <% if has_column_header && row_index.zero? %><% end %> + <% end %> + <% if rows.length > (has_column_header ? 1 : 0) %><% end %> +
+
+<% end %> From 213d0f5acea15d62a5a77b7bc6e733b2ccdf17cd Mon Sep 17 00:00:00 2001 From: Mark Vletter Date: Tue, 18 Aug 2026 10:13:32 +0200 Subject: [PATCH 2/2] Harden Notion table rendering --- lib/notion/notion.rb | 27 ++++++++++++++++++----- lib/notion/views/full.html.erb | 2 +- lib/notion/views/half_horizontal.html.erb | 2 +- lib/notion/views/half_vertical.html.erb | 2 +- lib/notion/views/quadrant.html.erb | 2 +- lib/notion/views/shared/_table.html.erb | 24 +++++++++++++++----- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/lib/notion/notion.rb b/lib/notion/notion.rb index c804f11..774f7cc 100644 --- a/lib/notion/notion.rb +++ b/lib/notion/notion.rb @@ -336,7 +336,8 @@ def format_blocks(blocks) end def format_table_block(block, block_data) - rows = fetch_table_rows(block["id"]).map do |row| + table_data = fetch_table_data(block["id"]) + rows = table_data[:rows].map do |row| Array(row.dig("table_row", "cells")).map { |cell| extract_rich_text(cell) } end @@ -348,13 +349,25 @@ def format_table_block(block, block_data) table_width: table_width, has_column_header: block_data["has_column_header"] == true, has_row_header: block_data["has_row_header"] == true, + has_more_rows: table_data[:has_more], rows: normalize_table_rows(rows, table_width) } end - def fetch_table_rows(table_id) - response = api_client.get_page_blocks(table_id, page_size: 100) - Array(response["results"]).select { |block| block["type"] == "table_row" } + def fetch_table_data(table_id) + @table_data ||= {} + @table_data[table_id] ||= begin + response = api_client.get_page_blocks(table_id, page_size: 100) + results = response.is_a?(Hash) ? response["results"] : nil + + { + rows: Array(results).select { |block| block.is_a?(Hash) && block["type"] == "table_row" }, + has_more: response.is_a?(Hash) && response["has_more"] == true + } + rescue StandardError => e + Rails.logger.error "Notion table API error: #{e.message}" + { rows: [], has_more: false } + end end def normalize_table_rows(rows, table_width) @@ -367,7 +380,11 @@ def normalize_table_rows(rows, table_width) end def extract_rich_text(rich_text) - text = Array(rich_text).map { |fragment| fragment["plain_text"] || fragment.dig("text", "content") }.join + text = Array(rich_text).filter_map do |fragment| + next unless fragment.is_a?(Hash) + + fragment["plain_text"] || fragment.dig("text", "content") + end.join strip_emojis(text) end diff --git a/lib/notion/views/full.html.erb b/lib/notion/views/full.html.erb index d87ef49..fc6125b 100644 --- a/lib/notion/views/full.html.erb +++ b/lib/notion/views/full.html.erb @@ -23,7 +23,7 @@
<% elsif display_type == "page" %> <% if items && items[:blocks] && items[:blocks].any? %> - <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" && Array(block[:rows]).any? && block[:table_width].to_i.positive? } %>
"> <% if has_table %>
diff --git a/lib/notion/views/half_horizontal.html.erb b/lib/notion/views/half_horizontal.html.erb index f95e4b5..e00c8e4 100644 --- a/lib/notion/views/half_horizontal.html.erb +++ b/lib/notion/views/half_horizontal.html.erb @@ -23,7 +23,7 @@
<% elsif display_type == "page" %> <% if items && items[:blocks] && items[:blocks].any? %> - <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" && Array(block[:rows]).any? && block[:table_width].to_i.positive? } %>
"> <% if has_table %>
diff --git a/lib/notion/views/half_vertical.html.erb b/lib/notion/views/half_vertical.html.erb index f4de6ca..41f06d4 100644 --- a/lib/notion/views/half_vertical.html.erb +++ b/lib/notion/views/half_vertical.html.erb @@ -23,7 +23,7 @@
<% elsif display_type == "page" %> <% if items && items[:blocks] && items[:blocks].any? %> - <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" && Array(block[:rows]).any? && block[:table_width].to_i.positive? } %>
"> <% if has_table %>
diff --git a/lib/notion/views/quadrant.html.erb b/lib/notion/views/quadrant.html.erb index 49a8705..3bbc557 100644 --- a/lib/notion/views/quadrant.html.erb +++ b/lib/notion/views/quadrant.html.erb @@ -23,7 +23,7 @@
<% elsif display_type == "page" %> <% if items && items[:blocks] && items[:blocks].any? %> - <% has_table = items[:blocks].any? { |block| block[:type] == "table" } %> + <% has_table = items[:blocks].any? { |block| block[:type] == "table" && Array(block[:rows]).any? && block[:table_width].to_i.positive? } %>
"> <% if has_table %>
diff --git a/lib/notion/views/shared/_table.html.erb b/lib/notion/views/shared/_table.html.erb index 548d85c..b8e6396 100644 --- a/lib/notion/views/shared/_table.html.erb +++ b/lib/notion/views/shared/_table.html.erb @@ -15,8 +15,11 @@ visible_source_columns = hidden_column_count.positive? ? limits[:columns] - 1 : source_column_count displayed_column_count = [source_column_count, limits[:columns]].min - hidden_row_count = source_rows.length > limits[:rows] ? source_rows.length - limits[:rows] + 1 : 0 - visible_source_rows = hidden_row_count.positive? ? limits[:rows] - 1 : source_rows.length + row_results_truncated = table[:has_more_rows] == true + needs_row_summary = source_rows.length > limits[:rows] || row_results_truncated + visible_source_rows = needs_row_summary ? [limits[:rows] - 1, source_rows.length].min : source_rows.length + hidden_row_count = source_rows.length - visible_source_rows + hidden_row_label = row_results_truncated ? "+#{[hidden_row_count, 1].max}+ rows" : "+#{hidden_row_count} rows" rows = source_rows.first(visible_source_rows).map.with_index do |row, row_index| cells = row.first(visible_source_columns) @@ -25,19 +28,23 @@ cells end - if hidden_row_count.positive? + if needs_row_summary summary_row = Array.new(displayed_column_count, "…") - summary_row[0] = "+#{hidden_row_count} rows" + summary_row[0] = hidden_row_label rows << summary_row end dense = rows.length > 8 || displayed_column_count > 6 line_limit = dense ? [limits[:lines], 2].min : limits[:lines] + line_limit = 1 if %w[half_horizontal quadrant].include?(current_layout) && rows.length == limits[:rows] %> <% if rows.any? && displayed_column_count.positive? %>