infotable 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +144 -10
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +19 -18
- data/app/assets/javascripts/infotable/controllers/table_controller.js +42 -23
- data/app/controllers/infotable/tables_controller.rb +1 -1
- data/app/helpers/infotable/tables_helper.rb +10 -25
- data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
- data/app/views/infotable/tables/_pagination.html.erb +4 -7
- data/app/views/infotable/tables/_table.html.erb +1 -1
- data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
- data/app/views/infotable/tables/filters/_range.html.erb +1 -1
- data/app/views/infotable/tables/filters/_select.html.erb +2 -2
- data/app/views/infotable/tables/filters/_text.html.erb +2 -2
- data/config/locales/de.yml +126 -0
- data/config/locales/el.yml +126 -0
- data/config/locales/en.yml +18 -4
- data/config/locales/es.yml +126 -0
- data/config/locales/fr.yml +126 -0
- data/config/locales/it.yml +126 -0
- data/config/locales/pl.yml +18 -4
- data/exe/infotable-mcp +13 -0
- data/lib/generators/infotable/table/table_generator.rb +23 -1
- data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
- data/lib/infotable/base.rb +5 -4
- data/lib/infotable/formatters/currency_formatter.rb +4 -12
- data/lib/infotable/formatters/date_formatter.rb +3 -9
- data/lib/infotable/formatters/duration_formatter.rb +51 -0
- data/lib/infotable/formatters/filesize_formatter.rb +14 -0
- data/lib/infotable/formatters/image_formatter.rb +31 -0
- data/lib/infotable/formatters/number_formatter.rb +46 -0
- data/lib/infotable/formatters/tags_formatter.rb +44 -0
- data/lib/infotable/formatters/truncate_formatter.rb +19 -0
- data/lib/infotable/mcp/introspection.rb +94 -0
- data/lib/infotable/mcp/server.rb +26 -0
- data/lib/infotable/mcp/table_checker.rb +80 -0
- data/lib/infotable/mcp/table_writer.rb +66 -0
- data/lib/infotable/mcp/tools.rb +122 -0
- data/lib/infotable/mcp.rb +30 -0
- data/lib/infotable/version.rb +1 -1
- data/lib/infotable.rb +22 -0
- metadata +51 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
it:
|
|
2
|
+
infotable:
|
|
3
|
+
table:
|
|
4
|
+
buttons:
|
|
5
|
+
export_csv: "Esporta CSV"
|
|
6
|
+
filters: "Filtri"
|
|
7
|
+
exporting: "Esportazione..."
|
|
8
|
+
refresh: "Aggiorna"
|
|
9
|
+
columns: "Colonne"
|
|
10
|
+
sort_hint: "Clicca per ordinare, Maiusc+clic aggiunge una colonna all'ordinamento"
|
|
11
|
+
loading:
|
|
12
|
+
initial: "Caricamento dati..."
|
|
13
|
+
more_items: "Caricamento di altri elementi..."
|
|
14
|
+
all_loaded: "Tutti gli elementi sono stati caricati"
|
|
15
|
+
resize_column: "ridimensiona colonna"
|
|
16
|
+
empty_state:
|
|
17
|
+
title: "Nessun dato trovato"
|
|
18
|
+
description: "Aggiungi dei record per vederli qui."
|
|
19
|
+
filtered_title: "Nessuna riga corrisponde ai filtri"
|
|
20
|
+
filtered_description: "Rimuovi un filtro o cerca qualcos'altro."
|
|
21
|
+
|
|
22
|
+
drawer:
|
|
23
|
+
label: "Filtri e colonne"
|
|
24
|
+
panel_title:
|
|
25
|
+
customize_columns: "Personalizza colonne"
|
|
26
|
+
filters: "Filtri"
|
|
27
|
+
columns: "Colonne"
|
|
28
|
+
tabs:
|
|
29
|
+
filters: "Filtri"
|
|
30
|
+
columns: "Colonne"
|
|
31
|
+
|
|
32
|
+
columns:
|
|
33
|
+
instructions: "Trascina per riordinare, fissa le colonne e mostra/nascondi:"
|
|
34
|
+
pin:
|
|
35
|
+
left: "Fissa a sinistra"
|
|
36
|
+
right: "Fissa a destra"
|
|
37
|
+
actions:
|
|
38
|
+
select_all: "Seleziona tutto"
|
|
39
|
+
deselect_all: "Deseleziona tutto"
|
|
40
|
+
reset_to_defaults: "🔄 Ripristina predefiniti"
|
|
41
|
+
|
|
42
|
+
filters:
|
|
43
|
+
actions:
|
|
44
|
+
clear_all: "Cancella tutto"
|
|
45
|
+
apply_filters: "Applica filtri"
|
|
46
|
+
clear_filter: "Cancella questo filtro"
|
|
47
|
+
active:
|
|
48
|
+
search: "Ricerca: «%{query}»"
|
|
49
|
+
remove: "Rimuovi il filtro %{filter}"
|
|
50
|
+
clear_all: "Cancella tutti i filtri"
|
|
51
|
+
from: "Da"
|
|
52
|
+
to: "A"
|
|
53
|
+
min: "Min"
|
|
54
|
+
max: "Max"
|
|
55
|
+
enter_value: "Inserisci un valore..."
|
|
56
|
+
all: "Tutti"
|
|
57
|
+
multi_select_hint: "Tieni premuto Ctrl/Cmd per selezionarne più di uno"
|
|
58
|
+
options_truncated: "Mostrate le prime %{shown} opzioni su %{total}"
|
|
59
|
+
boolean:
|
|
60
|
+
"true": "Vero"
|
|
61
|
+
"false": "Falso"
|
|
62
|
+
empty: "Vuoto"
|
|
63
|
+
not_empty: "Non vuoto"
|
|
64
|
+
text_ops:
|
|
65
|
+
contains: "Contiene"
|
|
66
|
+
exact: "Corrispondenza esatta"
|
|
67
|
+
not_contain: "Non contiene"
|
|
68
|
+
starts_with: "Inizia con"
|
|
69
|
+
ends_with: "Finisce con"
|
|
70
|
+
is_empty: "È vuoto"
|
|
71
|
+
is_not_empty: "Non è vuoto"
|
|
72
|
+
range_ops:
|
|
73
|
+
between: "Tra"
|
|
74
|
+
number_ops:
|
|
75
|
+
eq: "Uguale a"
|
|
76
|
+
neq: "Diverso da"
|
|
77
|
+
gt: "Maggiore di"
|
|
78
|
+
gte: "Maggiore o uguale a"
|
|
79
|
+
lt: "Minore di"
|
|
80
|
+
lte: "Minore o uguale a"
|
|
81
|
+
date_ops:
|
|
82
|
+
"on": "Il"
|
|
83
|
+
before: "Prima del"
|
|
84
|
+
after: "Dopo il"
|
|
85
|
+
|
|
86
|
+
formatters:
|
|
87
|
+
ago: "%{time} fa"
|
|
88
|
+
date:
|
|
89
|
+
short: "%d %b %Y"
|
|
90
|
+
long: "%d %B %Y"
|
|
91
|
+
time: "%H:%M"
|
|
92
|
+
datetime: "%d %b %Y %H:%M"
|
|
93
|
+
action: "Azione"
|
|
94
|
+
boolean:
|
|
95
|
+
"true": "Sì"
|
|
96
|
+
"false": "No"
|
|
97
|
+
duration:
|
|
98
|
+
short:
|
|
99
|
+
d: "%{count} g"
|
|
100
|
+
h: "%{count} h"
|
|
101
|
+
m: "%{count} min"
|
|
102
|
+
s: "%{count} s"
|
|
103
|
+
long:
|
|
104
|
+
d: { one: "%{count} giorno", few: "%{count} giorni", many: "%{count} giorni", other: "%{count} giorni" }
|
|
105
|
+
h: { one: "%{count} ora", few: "%{count} ore", many: "%{count} ore", other: "%{count} ore" }
|
|
106
|
+
m: { one: "%{count} minuto", few: "%{count} minuti", many: "%{count} minuti", other: "%{count} minuti" }
|
|
107
|
+
s: { one: "%{count} secondo", few: "%{count} secondi", many: "%{count} secondi", other: "%{count} secondi" }
|
|
108
|
+
|
|
109
|
+
batch:
|
|
110
|
+
selected: "%{count} selezionati"
|
|
111
|
+
clear: "Cancella selezione"
|
|
112
|
+
select_all: "Seleziona tutte le righe"
|
|
113
|
+
select_row: "Seleziona riga"
|
|
114
|
+
select_all_matching: "Seleziona tutti i %{count} risultati"
|
|
115
|
+
truncated: "Sono selezionate solo le prime %{max} righe"
|
|
116
|
+
|
|
117
|
+
search:
|
|
118
|
+
placeholder: "Cerca..."
|
|
119
|
+
label: "Cerca nella tabella"
|
|
120
|
+
|
|
121
|
+
pagination:
|
|
122
|
+
summary_html: "Visualizzazione da %{from} a %{to} di %{total} risultati"
|
|
123
|
+
summary_no_total_html: "Visualizzazione da %{from} a %{to}"
|
|
124
|
+
rows_per_page: "Righe per pagina:"
|
|
125
|
+
previous: "Precedente"
|
|
126
|
+
next: "Successivo"
|
data/config/locales/pl.yml
CHANGED
|
@@ -85,10 +85,26 @@ pl:
|
|
|
85
85
|
|
|
86
86
|
formatters:
|
|
87
87
|
ago: "%{time} temu"
|
|
88
|
+
date:
|
|
89
|
+
short: "%d %b %Y"
|
|
90
|
+
long: "%d %B %Y"
|
|
91
|
+
time: "%H:%M"
|
|
92
|
+
datetime: "%d %b %Y %H:%M"
|
|
88
93
|
action: "Akcja"
|
|
89
94
|
boolean:
|
|
90
95
|
"true": "Tak"
|
|
91
96
|
"false": "Nie"
|
|
97
|
+
duration:
|
|
98
|
+
short:
|
|
99
|
+
d: "%{count} d"
|
|
100
|
+
h: "%{count} godz."
|
|
101
|
+
m: "%{count} min"
|
|
102
|
+
s: "%{count} s"
|
|
103
|
+
long:
|
|
104
|
+
d: { one: "%{count} dzień", few: "%{count} dni", many: "%{count} dni", other: "%{count} dni" }
|
|
105
|
+
h: { one: "%{count} godzina", few: "%{count} godziny", many: "%{count} godzin", other: "%{count} godzin" }
|
|
106
|
+
m: { one: "%{count} minuta", few: "%{count} minuty", many: "%{count} minut", other: "%{count} minut" }
|
|
107
|
+
s: { one: "%{count} sekunda", few: "%{count} sekundy", many: "%{count} sekund", other: "%{count} sekund" }
|
|
92
108
|
|
|
93
109
|
batch:
|
|
94
110
|
selected: "Zaznaczono: %{count}"
|
|
@@ -103,10 +119,8 @@ pl:
|
|
|
103
119
|
label: "Szukaj w tabeli"
|
|
104
120
|
|
|
105
121
|
pagination:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
of: "z"
|
|
109
|
-
results: "wyników"
|
|
122
|
+
summary_html: "Wyświetlanie %{from} do %{to} z %{total} wyników"
|
|
123
|
+
summary_no_total_html: "Wyświetlanie %{from} do %{to}"
|
|
110
124
|
rows_per_page: "Wierszy na stronę:"
|
|
111
125
|
previous: "Poprzednia"
|
|
112
126
|
next: "Następna"
|
data/exe/infotable-mcp
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# MCP server (stdio) that lets an AI assistant create InfoTable tables in the host app:
|
|
3
|
+
# bundle exec infotable-mcp
|
|
4
|
+
# Run it from the Rails app's root, see "MCP server" in the README.
|
|
5
|
+
require "bundler/setup"
|
|
6
|
+
|
|
7
|
+
environment = File.expand_path("config/environment", Dir.pwd)
|
|
8
|
+
abort "infotable-mcp: run it from the root of a Rails app (no config/environment.rb in #{Dir.pwd})" unless File.exist?("#{environment}.rb")
|
|
9
|
+
|
|
10
|
+
require environment
|
|
11
|
+
require "infotable/mcp"
|
|
12
|
+
|
|
13
|
+
Infotable::Mcp::Server.run
|
|
@@ -9,6 +9,14 @@ module Infotable
|
|
|
9
9
|
|
|
10
10
|
class_option :only, type: :array, default: [], desc: "Only these columns"
|
|
11
11
|
class_option :skip, type: :array, default: [], desc: "Leave these columns out"
|
|
12
|
+
class_option :per_page, type: :numeric, default: 25, desc: "Rows per page"
|
|
13
|
+
class_option :sort, type: :string, desc: "Default sort as column:direction, e.g. created_at:desc"
|
|
14
|
+
class_option :toolbar, type: :string, desc: "Toolbar mode: header or rail"
|
|
15
|
+
class_option :row_click, type: :boolean, default: false, desc: "Clicking a row opens its row_link"
|
|
16
|
+
class_option :infinite_scroll, type: :boolean, default: false, desc: "Infinite scroll instead of pages"
|
|
17
|
+
class_option :countless, type: :boolean, default: false, desc: "Skip the total count (big tables)"
|
|
18
|
+
class_option :checkbox, type: :boolean, default: false, desc: "Row selection column"
|
|
19
|
+
class_option :authorize, type: :string, desc: "Ruby expression over `controller` that decides who sees the table"
|
|
12
20
|
|
|
13
21
|
# Attributes that must never end up in a table by accident
|
|
14
22
|
SENSITIVE = /password|digest|token|secret|otp|encrypted/
|
|
@@ -82,9 +90,23 @@ module Infotable
|
|
|
82
90
|
@model.reflect_on_all_associations(:belongs_to).map(&:name)
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
# Table-level settings chosen with the options above, one DSL line each
|
|
94
|
+
def setting_lines
|
|
95
|
+
lines = []
|
|
96
|
+
lines << "toolbar :#{options[:toolbar]}" if options[:toolbar].present?
|
|
97
|
+
lines << "infinite_scroll true" if options[:infinite_scroll]
|
|
98
|
+
lines << "countless" if options[:countless]
|
|
99
|
+
lines << "checkbox_column" if options[:checkbox]
|
|
100
|
+
lines << "row_click" if options[:row_click]
|
|
101
|
+
lines
|
|
102
|
+
end
|
|
103
|
+
|
|
85
104
|
def default_sort_line
|
|
86
105
|
names = column_specs.map { _1[:name] }
|
|
87
|
-
if
|
|
106
|
+
if options[:sort].present?
|
|
107
|
+
column, direction = options[:sort].split(":")
|
|
108
|
+
"default_sort :#{column}, :#{direction == 'desc' ? 'desc' : 'asc'}"
|
|
109
|
+
elsif names.include?("created_at")
|
|
88
110
|
"default_sort :created_at, :desc"
|
|
89
111
|
elsif names.any?
|
|
90
112
|
"default_sort :#{names.first}, :asc"
|
|
@@ -19,8 +19,15 @@ class <%= table_class_name %> < Infotable::Base
|
|
|
19
19
|
<% end -%>
|
|
20
20
|
<% end -%>
|
|
21
21
|
|
|
22
|
-
per_page
|
|
22
|
+
per_page <%= options[:per_page] %>
|
|
23
23
|
<% if default_sort_line %>
|
|
24
24
|
<%= default_sort_line %>
|
|
25
25
|
<% end -%>
|
|
26
|
+
<% setting_lines.each do |line| -%>
|
|
27
|
+
<%= line %>
|
|
28
|
+
<% end -%>
|
|
29
|
+
<% if options[:authorize].present? %>
|
|
30
|
+
|
|
31
|
+
authorize { |controller| <%= options[:authorize] %> }
|
|
32
|
+
<% end -%>
|
|
26
33
|
end
|
data/lib/infotable/base.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module Infotable
|
|
2
2
|
class Base
|
|
3
|
+
# Options column accepts; anything else is logged as a probable typo
|
|
4
|
+
COLUMN_OPTIONS = %i[label type sortable searchable filterable visible exportable pinned virtual virtual_sql
|
|
5
|
+
sql sort_sql value includes formatter formatter_options actions filter_type filter_options
|
|
6
|
+
filter_options_cache filter_options_limit filter_multi_select width].freeze
|
|
7
|
+
|
|
3
8
|
class << self
|
|
4
9
|
attr_accessor :table_columns, :table_query, :table_query_sql, :pagination_settings, :sort_settings, :height_settings, :infinite_scroll_settings, :row_link_settings, :row_attr_settings, :slim_rows_settings, :row_size_settings, :filters_button_settings, :display_settings, :nil_value_settings, :empty_value_settings, :table_authorize, :checkbox_settings, :batch_action_list, :countless_setting, :url_state_setting, :resizable_columns_setting
|
|
5
10
|
|
|
@@ -171,10 +176,6 @@ module Infotable
|
|
|
171
176
|
end
|
|
172
177
|
|
|
173
178
|
# Define a column
|
|
174
|
-
COLUMN_OPTIONS = %i[label type sortable searchable filterable visible exportable pinned virtual virtual_sql
|
|
175
|
-
sql sort_sql value includes formatter formatter_options actions filter_type filter_options
|
|
176
|
-
filter_options_cache filter_options_limit filter_multi_select width].freeze
|
|
177
|
-
|
|
178
179
|
def column(name, **options)
|
|
179
180
|
@table_columns ||= []
|
|
180
181
|
unknown = options.keys - COLUMN_OPTIONS
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
module Infotable
|
|
2
2
|
module Formatters
|
|
3
|
+
# formatter: :currency
|
|
4
|
+
# formatter_options: { unit: "$", precision: 2, delimiter: ",", separator: "." }
|
|
5
|
+
# Options you leave out come from I18n (`number.currency.format`) for the current locale.
|
|
3
6
|
class CurrencyFormatter < BaseFormatter
|
|
4
7
|
def format(value, record, options = {})
|
|
5
8
|
return "" if value.blank?
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
precision = options[:precision] || 2
|
|
9
|
-
delimiter = options[:delimiter] || ","
|
|
10
|
-
separator = options[:separator] || "."
|
|
11
|
-
|
|
12
|
-
number_to_currency(
|
|
13
|
-
value,
|
|
14
|
-
unit: unit,
|
|
15
|
-
precision: precision,
|
|
16
|
-
delimiter: delimiter,
|
|
17
|
-
separator: separator
|
|
18
|
-
)
|
|
10
|
+
number_to_currency(value, options.slice(:unit, :precision, :delimiter, :separator))
|
|
19
11
|
end
|
|
20
12
|
end
|
|
21
13
|
end
|
|
@@ -10,16 +10,10 @@ module Infotable
|
|
|
10
10
|
format = options[:format] || :default
|
|
11
11
|
|
|
12
12
|
case format
|
|
13
|
-
when :short
|
|
14
|
-
|
|
15
|
-
when :long
|
|
16
|
-
value.strftime("%B %d, %Y")
|
|
17
|
-
when :time
|
|
18
|
-
value.strftime("%I:%M %p")
|
|
19
|
-
when :datetime
|
|
20
|
-
value.strftime("%b %d, %Y %I:%M %p")
|
|
13
|
+
when :short, :long, :time, :datetime
|
|
14
|
+
view_context.l(value, format: view_context.t("infotable.formatters.date.#{format}"))
|
|
21
15
|
when :relative
|
|
22
|
-
time_ago_in_words(value)
|
|
16
|
+
view_context.content_tag(:span, time_ago_in_words(value), title: value.strftime("%Y-%m-%d %H:%M"))
|
|
23
17
|
when :dot_date
|
|
24
18
|
value.strftime("%d.%m.%Y")
|
|
25
19
|
when :default
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :duration
|
|
4
|
+
# Seconds as "1h 05m" (:short, default) or "1 hour 5 minutes" (:long).
|
|
5
|
+
# formatter_options: { format: :short, parts: 2 } # parts: how many units to show, from the largest
|
|
6
|
+
class DurationFormatter < BaseFormatter
|
|
7
|
+
UNITS = { d: 86_400, h: 3600, m: 60, s: 1 }.freeze
|
|
8
|
+
|
|
9
|
+
def format(value, record, options = {})
|
|
10
|
+
return "" if value.nil? || value == ""
|
|
11
|
+
|
|
12
|
+
seconds = Float(value.to_s, exception: false)
|
|
13
|
+
return value.to_s if seconds.nil? || seconds.negative?
|
|
14
|
+
|
|
15
|
+
parts = split(seconds.round).first(options.fetch(:parts, 2))
|
|
16
|
+
parts = [ [ :s, 0 ] ] if parts.empty?
|
|
17
|
+
options[:format].to_s == "long" ? long(parts) : short(parts)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# [[:h, 1], [:m, 5]]; units with nothing in them are left out
|
|
23
|
+
def split(seconds)
|
|
24
|
+
UNITS.filter_map do |unit, size|
|
|
25
|
+
count, seconds = seconds.divmod(size)
|
|
26
|
+
[ unit, count ] if count.positive?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def short(parts)
|
|
31
|
+
parts.each_with_index.map do |(unit, count), index|
|
|
32
|
+
number = index.zero? ? count.to_s : count.to_s.rjust(2, "0")
|
|
33
|
+
view_context.t("infotable.formatters.duration.short.#{unit}", count: number)
|
|
34
|
+
end.join(" ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def long(parts)
|
|
38
|
+
parts.map { |unit, count| view_context.t("infotable.formatters.duration.long.#{unit}.#{plural_key(count)}", count: count) }.join(" ")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The key is picked here, so Polish (1 / 2-4 / 5+) is right without rails-i18n's pluralization rules
|
|
42
|
+
def plural_key(count)
|
|
43
|
+
return count == 1 ? :one : :other unless I18n.locale.to_s == "pl"
|
|
44
|
+
return :one if count == 1
|
|
45
|
+
return :few if (2..4).cover?(count % 10) && !(12..14).cover?(count % 100)
|
|
46
|
+
|
|
47
|
+
:many
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :filesize
|
|
4
|
+
# Bytes as a human size: 1536 => "1.5 KB". `formatter_options: { precision: 2 }` (significant digits)
|
|
5
|
+
class FilesizeFormatter < BaseFormatter
|
|
6
|
+
def format(value, record, options = {})
|
|
7
|
+
return "" if value.nil? || value == ""
|
|
8
|
+
return value.to_s if Float(value.to_s, exception: false).nil?
|
|
9
|
+
|
|
10
|
+
view_context.number_to_human_size(value, precision: options.fetch(:precision, 2), strip_insignificant_zeros: true)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :image
|
|
4
|
+
# A square thumbnail (use :avatar for round pictures); the value is the image URL.
|
|
5
|
+
# formatter_options: {
|
|
6
|
+
# size: :md, # :sm (32px), :md (48px), :lg (64px)
|
|
7
|
+
# alt: ->(record) { record.name }, # alt text, a String or a Proc
|
|
8
|
+
# link: true # open the full image in a new tab
|
|
9
|
+
# }
|
|
10
|
+
class ImageFormatter < BaseFormatter
|
|
11
|
+
SIZES = { sm: "h-8 w-8", md: "h-12 w-12", lg: "h-16 w-16" }.freeze
|
|
12
|
+
|
|
13
|
+
def format(value, record, options = {})
|
|
14
|
+
return "" unless image_url?(value)
|
|
15
|
+
|
|
16
|
+
alt = options[:alt].is_a?(Proc) ? view_context.instance_exec(record, &options[:alt]) : options[:alt]
|
|
17
|
+
size = SIZES.fetch((options[:size] || :md).to_sym, SIZES[:md])
|
|
18
|
+
image = tag.img(src: value.to_s, alt: alt.to_s, loading: "lazy", class: "#{size} rounded-md object-cover flex-none")
|
|
19
|
+
return image unless options[:link]
|
|
20
|
+
|
|
21
|
+
link_to(image, value.to_s, target: "_blank", rel: "noopener noreferrer")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def image_url?(value)
|
|
27
|
+
LinkFormatter.safe_url?(value) && value.to_s.match?(%r{\A(https?:)?//|\A/})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :number
|
|
4
|
+
# formatter_options: {
|
|
5
|
+
# precision: 2, # decimals; without it the number is shown as it is
|
|
6
|
+
# delimiter: ",", # thousands separator, default ","
|
|
7
|
+
# separator: ".", # decimal separator, default "."
|
|
8
|
+
# unit: "ms", # appended after the number
|
|
9
|
+
# compact: true, # 1.2k, 3.4M, 5B (precision defaults to 1)
|
|
10
|
+
# zero: "-" # shown instead of 0
|
|
11
|
+
# }
|
|
12
|
+
class NumberFormatter < BaseFormatter
|
|
13
|
+
COMPACT_STEPS = [ [ 1_000_000_000, "B" ], [ 1_000_000, "M" ], [ 1_000, "k" ] ].freeze
|
|
14
|
+
|
|
15
|
+
def format(value, record, options = {})
|
|
16
|
+
return "" if value.nil? || value == ""
|
|
17
|
+
|
|
18
|
+
number = Float(value.to_s, exception: false)
|
|
19
|
+
return value.to_s if number.nil?
|
|
20
|
+
return options[:zero].to_s if options.key?(:zero) && number.zero?
|
|
21
|
+
|
|
22
|
+
text = options[:compact] ? compact(value, number, options) : plain(value, options)
|
|
23
|
+
text = "#{text} #{options[:unit]}" if options[:unit].present?
|
|
24
|
+
content_tag(:span, text, class: "tabular-nums")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def plain(value, options)
|
|
30
|
+
delimiter = options.fetch(:delimiter, ",")
|
|
31
|
+
separator = options.fetch(:separator, ".")
|
|
32
|
+
return view_context.number_with_delimiter(value, delimiter: delimiter, separator: separator) unless options[:precision]
|
|
33
|
+
|
|
34
|
+
view_context.number_with_precision(value, precision: options[:precision], delimiter: delimiter, separator: separator)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def compact(value, number, options)
|
|
38
|
+
divisor, suffix = COMPACT_STEPS.find { |step, _| number.abs >= step }
|
|
39
|
+
return plain(value, options) unless divisor
|
|
40
|
+
|
|
41
|
+
view_context.number_with_precision(number / divisor, precision: options.fetch(:precision, 1),
|
|
42
|
+
separator: options.fetch(:separator, "."), strip_insignificant_zeros: true) + suffix
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :tags (alias :list)
|
|
4
|
+
# The value is an Array, a relation or a comma separated string; every item becomes a small badge.
|
|
5
|
+
# formatter_options: {
|
|
6
|
+
# limit: 3, # show this many, then "+N" (the rest is in its tooltip)
|
|
7
|
+
# label: ->(item) { item.name }, # text of an item, defaults to item.to_s
|
|
8
|
+
# colors: { "admin" => "red" }, # item => badge colour, as in :badge (default gray)
|
|
9
|
+
# separator: ", " # plain text joined with this instead of badges
|
|
10
|
+
# }
|
|
11
|
+
class TagsFormatter < BaseFormatter
|
|
12
|
+
def format(value, record, options = {})
|
|
13
|
+
labels = items(value).map { label_for(_1, options) }.reject(&:blank?)
|
|
14
|
+
return "" if labels.empty?
|
|
15
|
+
|
|
16
|
+
return safe_join(labels, options[:separator].to_s) if options[:separator]
|
|
17
|
+
|
|
18
|
+
limit = options[:limit]
|
|
19
|
+
shown, rest = limit ? [ labels.first(limit), labels.drop(limit) ] : [ labels, [] ]
|
|
20
|
+
badges = shown.map { badge(_1, options) }
|
|
21
|
+
badges << content_tag(:span, "+#{rest.size}", title: rest.join(", "), class: "text-xs text-gray-500 dark:text-gray-400") if rest.any?
|
|
22
|
+
content_tag(:div, safe_join(badges), class: "flex flex-wrap items-center gap-1")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def items(value)
|
|
28
|
+
return [] if value.nil?
|
|
29
|
+
return value.split(",").map(&:strip) if value.is_a?(String)
|
|
30
|
+
|
|
31
|
+
value.respond_to?(:to_a) ? value.to_a : [ value ]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def label_for(item, options)
|
|
35
|
+
label = options[:label].is_a?(Proc) ? view_context.instance_exec(item, &options[:label]) : item
|
|
36
|
+
label.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def badge(text, options)
|
|
40
|
+
BadgeFormatter.new(view_context).format(text, nil, label: text, size: :small, colors: options[:colors])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Formatters
|
|
3
|
+
# formatter: :truncate
|
|
4
|
+
# Long text cut to `length` characters; the full text stays in the cell's tooltip.
|
|
5
|
+
# formatter_options: { length: 60, omission: "…" }
|
|
6
|
+
class TruncateFormatter < BaseFormatter
|
|
7
|
+
def format(value, record, options = {})
|
|
8
|
+
return "" if value.blank?
|
|
9
|
+
|
|
10
|
+
text = value.to_s
|
|
11
|
+
length = options.fetch(:length, 60)
|
|
12
|
+
return text if text.length <= length
|
|
13
|
+
|
|
14
|
+
shortened = view_context.truncate(text, length: length, omission: options.fetch(:omission, "…"))
|
|
15
|
+
content_tag(:span, shortened, title: text)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "generators/infotable/table/table_generator"
|
|
2
|
+
|
|
3
|
+
module Infotable
|
|
4
|
+
module Mcp
|
|
5
|
+
# What the host app looks like: models and their columns, tables that exist, the DSL of this gem version.
|
|
6
|
+
module Introspection
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def models
|
|
10
|
+
Rails.application.eager_load!
|
|
11
|
+
ActiveRecord::Base.descendants.reject { _1.abstract_class? || _1.name.nil? || !_1.table_exists? }.sort_by(&:name).map { describe_model(_1) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def describe_model(model)
|
|
15
|
+
{
|
|
16
|
+
name: model.name,
|
|
17
|
+
table: model.table_name,
|
|
18
|
+
columns: model.columns.to_h { [ _1.name, _1.type.to_s ] },
|
|
19
|
+
enums: model.defined_enums.transform_values(&:keys),
|
|
20
|
+
associations: model.reflect_on_all_associations.map { { name: _1.name, kind: _1.macro, model: _1.options[:class_name] || _1.name.to_s.classify } }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tables
|
|
25
|
+
Rails.application.eager_load!
|
|
26
|
+
Infotable::Base.descendants.reject { _1.name.nil? }.sort_by(&:name).map { describe_table(_1) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def describe_table(table)
|
|
30
|
+
{
|
|
31
|
+
class: table.name,
|
|
32
|
+
model: (table.base_query.model.name rescue nil),
|
|
33
|
+
columns: table.columns.map { |col| col.slice(:name, :type, :sortable, :searchable, :filterable, :filter_type, :visible, :pinned).compact },
|
|
34
|
+
per_page: table.pagination_settings[:per_page],
|
|
35
|
+
default_sort: table.sort_settings.presence,
|
|
36
|
+
toolbar: table.toolbar_mode,
|
|
37
|
+
batch_actions: table.batch_action_list.map { _1[:name] },
|
|
38
|
+
row_click: table.row_click?,
|
|
39
|
+
infinite_scroll: table.infinite_scroll_enabled?,
|
|
40
|
+
countless: table.countless?,
|
|
41
|
+
no_authorize_block: table.table_authorize.nil?
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The DSL as this gem version knows it, read from the code so it cannot drift from the docs
|
|
46
|
+
def dsl
|
|
47
|
+
{
|
|
48
|
+
version: Infotable::VERSION,
|
|
49
|
+
column_options: Infotable::Base::COLUMN_OPTIONS,
|
|
50
|
+
column_types: Infotable::Generators::TableGenerator::TYPE_MAP.values.map { _1[:type] }.uniq + %i[badge currency percent link avatar action],
|
|
51
|
+
filter_types: %i[text boolean number date select],
|
|
52
|
+
formatters: Infotable::Formatters.built_in.keys,
|
|
53
|
+
toolbar_modes: %i[header rail],
|
|
54
|
+
table_settings: %i[query query_sql per_page per_page_options default_sort infinite_scroll countless url_state resizable_columns
|
|
55
|
+
toolbar row_click row_link row_class row_data row_size checkbox_column batch_actions authorize export_columns
|
|
56
|
+
table_title table_subtitle table_height nil_value empty_value],
|
|
57
|
+
batch_action_options: { required: %i[url], optional: %i[label method confirm color], methods: Infotable::BatchActions::METHODS, colors: Infotable::BatchActions::COLORS },
|
|
58
|
+
generate_table_options: Infotable::Generators::TableGenerator.class_options.keys
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Questions to put to the user before generating a table, each with a default proposed from the model
|
|
63
|
+
def table_options(model_name)
|
|
64
|
+
model = model_name.to_s.safe_constantize
|
|
65
|
+
return nil unless model.is_a?(Class) && model < ActiveRecord::Base
|
|
66
|
+
|
|
67
|
+
columns = model.column_names.reject { Infotable::Generators::TableGenerator::SENSITIVE.match?(_1) }
|
|
68
|
+
enums = model.defined_enums.keys
|
|
69
|
+
sort = columns.include?("created_at") ? "created_at:desc" : "#{model.primary_key}:asc"
|
|
70
|
+
belongs_to = model.reflect_on_all_associations(:belongs_to).map(&:name)
|
|
71
|
+
|
|
72
|
+
{
|
|
73
|
+
model: model.name,
|
|
74
|
+
notes: [
|
|
75
|
+
("enum columns (#{enums.join(', ')}) become badges with a select filter" if enums.any?),
|
|
76
|
+
("belongs_to #{belongs_to.join(', ')}: generated as a commented hint, a column needs a join" if belongs_to.any?),
|
|
77
|
+
"foreign keys and sensitive columns (password, token...) are left out"
|
|
78
|
+
].compact,
|
|
79
|
+
questions: [
|
|
80
|
+
{ param: "only", ask: "Which columns?", choices: columns, default: "all of the choices" },
|
|
81
|
+
{ param: "sort", ask: "Default sort (column:direction)?", choices: columns, default: sort },
|
|
82
|
+
{ param: "per_page", ask: "Rows per page?", choices: [ 10, 25, 50, 100 ], default: 25 },
|
|
83
|
+
{ param: "toolbar", ask: "Toolbar: header bar or slim rail?", choices: %w[header rail], default: "header" },
|
|
84
|
+
{ param: "checkbox", ask: "Row selection column?", default: false },
|
|
85
|
+
{ param: "row_click", ask: "Clicking a row opens it (needs row_link)?", default: false },
|
|
86
|
+
{ param: "infinite_scroll", ask: "Infinite scroll instead of pages?", default: false },
|
|
87
|
+
{ param: "countless", ask: "Skip the total count (tables over ~1M rows)?", default: false },
|
|
88
|
+
{ param: "authorize", ask: "Who may see the table? A Ruby expression over `controller`", default: "controller.current_user.present?" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Infotable
|
|
2
|
+
module Mcp
|
|
3
|
+
module Server
|
|
4
|
+
TOOLS = [ ListModels, ListTables, DescribeDsl, TableOptions, GenerateTable, ValidateTable ].freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build
|
|
9
|
+
readme = ::MCP::Resource.new(uri: "infotable://readme", name: "infotable-readme", title: "InfoTable README",
|
|
10
|
+
description: "Full documentation of the table DSL", mime_type: "text/markdown")
|
|
11
|
+
server = ::MCP::Server.new(
|
|
12
|
+
name: "infotable", version: Infotable::VERSION, tools: TOOLS, prompts: [ NewTablePrompt ], resources: [ readme ],
|
|
13
|
+
instructions: "Creates InfoTable tables in this Rails app. Start with list_models and table_options; ask the user for the settings before generate_table."
|
|
14
|
+
)
|
|
15
|
+
server.resources_read_handler do |params|
|
|
16
|
+
[ { uri: params[:uri], mimeType: "text/markdown", text: File.read(Infotable.root.join("README.md")) } ]
|
|
17
|
+
end
|
|
18
|
+
server
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
::MCP::Server::Transports::StdioTransport.new(build).open
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|