tabletastic 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +169 -26
- data/VERSION +1 -1
- data/lib/tabletastic.rb +81 -34
- data/spec/tabletastic_spec.rb +47 -4
- data/tabletastic.gemspec +56 -0
- metadata +3 -2
data/README.rdoc
CHANGED
@@ -4,46 +4,189 @@ Inspired by the projects table_builder and formtastic,
|
|
4
4
|
I realized how often I created tables for my active record collections.
|
5
5
|
This is my attempt to simply this (the default scaffold):
|
6
6
|
|
7
|
-
|
7
|
+
<table>
|
8
|
+
<tr>
|
9
|
+
<th>Title</th>
|
10
|
+
<th>Body</th>
|
11
|
+
<th>Author Id</th>
|
12
|
+
</tr>
|
13
|
+
<% for post in @posts %>
|
8
14
|
<tr>
|
9
|
-
<
|
10
|
-
<
|
11
|
-
<
|
12
|
-
|
13
|
-
|
14
|
-
<
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
<td><%=h author.name %></td>
|
19
|
-
<td><%= link_to "Show", post %></td>
|
20
|
-
<td><%= link_to "Edit", edit_post_path(post) %></td>
|
21
|
-
<td><%= link_to "Destroy", post, :confirm => 'Are you sure?', :method => :delete %></td>
|
22
|
-
</tr>
|
23
|
-
<% end %>
|
24
|
-
</table>
|
15
|
+
<td><%=h post.title %></td>
|
16
|
+
<td><%=h post.body %></td>
|
17
|
+
<td><%=h post.author_id %></td>
|
18
|
+
<td><%= link_to "Show", post %></td>
|
19
|
+
<td><%= link_to "Edit", edit_post_path(post) %></td>
|
20
|
+
<td><%= link_to "Destroy", post, :confirm => 'Are you sure?', :method => :delete %></td>
|
21
|
+
</tr>
|
22
|
+
<% end %>
|
23
|
+
</table>
|
25
24
|
|
26
25
|
into this:
|
27
26
|
|
28
|
-
|
29
|
-
|
30
|
-
|
27
|
+
<% table_for(@posts) do |t| %>
|
28
|
+
<%= t.data :title, :body, :author %>
|
29
|
+
<% end %>
|
30
|
+
|
31
|
+
and still output the same effective results, but with all the semantic
|
32
|
+
goodness that tabular data should have, i.e. a +<thead>+ and +<tbody>+ element.
|
31
33
|
|
32
34
|
|
33
35
|
== Warning
|
34
|
-
THIS PROJECT IS UNDER HEAVY DEVELOPMENT.
|
35
|
-
IT IS NOT RECOMMENDED FOR USE IN PRODUCTION APPLICATIONS
|
36
36
|
|
37
|
+
This project is still being actively developed. As such, future updates might not be backwards-compatible.
|
38
|
+
|
39
|
+
== Installation
|
40
|
+
|
41
|
+
In your Rails project, as a gem:
|
42
|
+
config.gem "tabletastic", :source => "http://gemcutter.org"
|
43
|
+
|
44
|
+
Or, for if you're behind the times, as a plugin:
|
45
|
+
script/plugin install git://github.com/jgdavey/tabletastic.git
|
46
|
+
|
47
|
+
|
48
|
+
== Usage
|
49
|
+
|
50
|
+
By default, you can just use the table_for method to build up your table.
|
51
|
+
Assuming you have a Post model with title and body, that belongs to an Author model with a name,
|
52
|
+
you can just use the helper. It will try to detect all content fields and belongs to associations.
|
53
|
+
|
54
|
+
In your view, simply calling:
|
55
|
+
|
56
|
+
<% table_for(@posts) do |t| %>
|
57
|
+
<%= t.data %>
|
58
|
+
<% end %>
|
59
|
+
|
60
|
+
will produce html like this:
|
61
|
+
|
62
|
+
<table id="posts">
|
63
|
+
<thead>
|
64
|
+
<tr>
|
65
|
+
<th>Title</th>
|
66
|
+
<th>Body</th>
|
67
|
+
<th>Author</th>
|
68
|
+
</tr>
|
69
|
+
</thead>
|
70
|
+
<tbody>
|
71
|
+
<tr class="post odd" id="post_1">
|
72
|
+
<td>Something</td>
|
73
|
+
<td>Lorem ipsum dolor sit amet consequat. Duis aute irure dolor.</td>
|
74
|
+
<td>Jim Beam</td>
|
75
|
+
</tr>
|
76
|
+
<tr class="post even" id="post_2">
|
77
|
+
<td>Second Post</td>
|
78
|
+
<td>This is the second post</td>
|
79
|
+
<td>Jack Daniels</td>
|
80
|
+
</tr>
|
81
|
+
<tr class="post odd" id="post_3">
|
82
|
+
<td>Something else</td>
|
83
|
+
<td>Blah!</td>
|
84
|
+
<td></td>
|
85
|
+
</tr>
|
86
|
+
</tbody>
|
87
|
+
</table>
|
88
|
+
|
89
|
+
|
90
|
+
To limit the fields, change the order, or to include fields that are excluded by default (such as created_at),
|
91
|
+
You can list methods to call on each resource:
|
92
|
+
|
93
|
+
<% table_for(@posts) do |t| %>
|
94
|
+
<%= t.data :author, :title, :created_at %>
|
95
|
+
<% end %>
|
96
|
+
|
97
|
+
will produce html like:
|
98
|
+
|
99
|
+
<table id="posts">
|
100
|
+
<thead>
|
101
|
+
<tr>
|
102
|
+
<th>Author</th>
|
103
|
+
<th>Title</th>
|
104
|
+
<th>Created at</th>
|
105
|
+
</tr>
|
106
|
+
</thead>
|
107
|
+
<tbody>
|
108
|
+
<tr id="post_1" class="post odd">
|
109
|
+
<td>Jim Beam</td>
|
110
|
+
<td>Something</td>
|
111
|
+
<td>2009-11-15 02:42:48 UTC</td>
|
112
|
+
</tr>
|
113
|
+
<tr id="post_2" class="post even">
|
114
|
+
<td>Jack Daniels</td>
|
115
|
+
<td>Second Post</td>
|
116
|
+
<td>2009-11-16 00:11:00 UTC</td>
|
117
|
+
</tr>
|
118
|
+
<tr id="post_3" class="post odd">
|
119
|
+
<td></td>
|
120
|
+
<td>Something else</td>
|
121
|
+
<td>2009-11-16 00:11:30 UTC</td>
|
122
|
+
</tr>
|
123
|
+
</tbody>
|
124
|
+
</table>
|
125
|
+
|
126
|
+
For even greater flexibility, you can pass +data+ a block:
|
127
|
+
|
128
|
+
<% table_for(@posts) do |t| %>
|
129
|
+
<% t.data do %>
|
130
|
+
<%= t.cell(:title, :cell_html => {:class => "titlestring"}) %>
|
131
|
+
<%= t.cell(:body, :heading => "Content") {|p| truncate(p.body, 30)} %>
|
132
|
+
<%= t.cell(:author) {|p| p.author && link_to(p.author.name, p.author) } %>
|
133
|
+
<%= t.cell(:edit, :heading => '') {|p| link_to "Edit", edit_post_path(p) } %>
|
134
|
+
<% end -%>
|
135
|
+
<% end %>
|
136
|
+
|
137
|
+
will product html like:
|
138
|
+
|
139
|
+
<table id="posts">
|
140
|
+
<thead>
|
141
|
+
<tr>
|
142
|
+
<th>Title</th>
|
143
|
+
<th>Content</th>
|
144
|
+
<th>Author</th>
|
145
|
+
<th></th>
|
146
|
+
</tr>
|
147
|
+
</thead>
|
148
|
+
<tbody>
|
149
|
+
<tr class="post odd" id="post_1">
|
150
|
+
<td class="titlestring">Something</td>
|
151
|
+
<td>Lorem ipsum dolor sit amet,...</td>
|
152
|
+
<td>
|
153
|
+
<a href="/authors/1">Jim Bean</a>
|
154
|
+
</td>
|
155
|
+
<td>
|
156
|
+
<a href="/posts/1/edit">Edit</a>
|
157
|
+
</td>
|
158
|
+
</tr>
|
159
|
+
<tr class="post even" id="post_2">
|
160
|
+
<td class="titlestring">Second Post</td>
|
161
|
+
<td>This is the second post</td>
|
162
|
+
<td>
|
163
|
+
<a href="/authors/2">Jack Daniels</a>
|
164
|
+
</td>
|
165
|
+
<td>
|
166
|
+
<a href="/posts/2/edit">Edit</a>
|
167
|
+
</td>
|
168
|
+
</tr>
|
169
|
+
<tr class="post odd" id="post_3">
|
170
|
+
<td class="titlestring">Something else</td>
|
171
|
+
<td>Blah!</td>
|
172
|
+
<td></td>
|
173
|
+
<td>
|
174
|
+
<a href="/posts/3/edit">Edit</a>
|
175
|
+
</td>
|
176
|
+
</tr>
|
177
|
+
</tbody>
|
178
|
+
</table>
|
179
|
+
|
180
|
+
|
181
|
+
If it _still_ isn't flexible enough for your needs, it might be time to return to static html/erb.
|
37
182
|
|
38
183
|
== Note on Patches/Pull Requests
|
39
184
|
|
40
185
|
* Fork the project.
|
41
186
|
* Make your feature addition or bug fix.
|
42
|
-
* Add tests for it. This is important so I don't break it in a
|
43
|
-
future version unintentionally.
|
187
|
+
* Add tests for it. This is important so I don't break it in a future version unintentionally.
|
44
188
|
* Commit, do not mess with rakefile, version, or history.
|
45
|
-
(if you want to have your own version, that is fine but
|
46
|
-
bump version in a commit by itself I can ignore when I pull)
|
189
|
+
(if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
|
47
190
|
* Send me a pull request. Bonus points for topic branches.
|
48
191
|
|
49
192
|
== Copyright
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0
|
1
|
+
0.1.0
|
data/lib/tabletastic.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
module Tabletastic
|
2
2
|
|
3
|
+
# returns and outputs a table for the given active record collection
|
3
4
|
def table_for(collection, *args)
|
4
5
|
options = args.extract_options!
|
5
6
|
options[:html] ||= {}
|
@@ -14,35 +15,64 @@ module Tabletastic
|
|
14
15
|
end
|
15
16
|
|
16
17
|
class TableBuilder
|
17
|
-
@@association_methods = %w[
|
18
|
+
@@association_methods = %w[display_name full_name name title username login value to_s]
|
19
|
+
attr_accessor :field_labels
|
18
20
|
|
19
21
|
def initialize(collection, template)
|
20
22
|
@collection, @template = collection, template
|
21
23
|
end
|
22
24
|
|
25
|
+
# builds up the fields that the table will include,
|
26
|
+
# returns table head and body with all data
|
23
27
|
def data(*args, &block)
|
24
28
|
if block_given?
|
25
29
|
yield self
|
26
|
-
@template.concat(
|
30
|
+
@template.concat(head)
|
27
31
|
@template.concat(body)
|
28
32
|
else
|
29
33
|
@fields = args unless args.empty?
|
30
|
-
|
34
|
+
@field_labels = fields.map { |f| f.to_s.humanize }
|
35
|
+
[head, body].join("")
|
31
36
|
end
|
32
37
|
end
|
33
38
|
|
34
|
-
def
|
39
|
+
def cell(*args, &block)
|
40
|
+
options = args.extract_options!
|
41
|
+
@field_labels ||= []
|
42
|
+
@fields ||= []
|
43
|
+
|
44
|
+
method_or_attribute = args.first.to_sym
|
45
|
+
|
46
|
+
if cell_html = options.delete(:cell_html)
|
47
|
+
@fields << [method_or_attribute, cell_html]
|
48
|
+
elsif block_given?
|
49
|
+
@fields << block.to_proc
|
50
|
+
else
|
51
|
+
@fields << method_or_attribute
|
52
|
+
end
|
53
|
+
|
54
|
+
if heading = options.delete(:heading)
|
55
|
+
@field_labels << heading
|
56
|
+
else
|
57
|
+
@field_labels << method_or_attribute.to_s.humanize
|
58
|
+
end
|
59
|
+
|
60
|
+
return "" # Since this will likely be called with <%= erb %>, this suppresses strange output
|
61
|
+
end
|
62
|
+
|
63
|
+
def head
|
64
|
+
@field_labels ||= fields
|
35
65
|
content_tag(:thead) do
|
36
66
|
header_row
|
37
67
|
end
|
38
68
|
end
|
39
69
|
|
40
70
|
def header_row
|
41
|
-
|
42
|
-
|
43
|
-
|
71
|
+
content_tag(:tr) do
|
72
|
+
@field_labels.inject("") do |result,field|
|
73
|
+
result += content_tag(:th, field)
|
74
|
+
end
|
44
75
|
end
|
45
|
-
output += "</tr>"
|
46
76
|
end
|
47
77
|
|
48
78
|
def body
|
@@ -61,49 +91,66 @@ module Tabletastic
|
|
61
91
|
end
|
62
92
|
|
63
93
|
def tds_for_row(record)
|
64
|
-
fields.inject("") do |cells,
|
65
|
-
|
94
|
+
fields.inject("") do |cells, field_or_array|
|
95
|
+
field = field_or_array
|
96
|
+
if field_or_array.is_a?(Array)
|
97
|
+
field = field_or_array.first
|
98
|
+
html_options = field_or_array.last
|
99
|
+
end
|
100
|
+
cells += content_tag(:td, cell_data(record, field), html_options)
|
66
101
|
end
|
67
102
|
end
|
68
103
|
|
69
|
-
def
|
70
|
-
|
104
|
+
def cell_data(record, method_or_attribute_or_proc)
|
105
|
+
# Get the attribute or association in question
|
106
|
+
result = send_or_call(record, method_or_attribute_or_proc)
|
107
|
+
# If we already have a string, just return it
|
71
108
|
return result if result.is_a?(String)
|
109
|
+
|
110
|
+
# If we don't have a string, its likely an association
|
111
|
+
# Try to detect which method to use for stringifying the attribute
|
72
112
|
to_string = detect_string_method(result)
|
73
113
|
result.send(to_string) if to_string
|
74
114
|
end
|
75
115
|
|
116
|
+
def fields
|
117
|
+
return @fields if defined?(@fields)
|
118
|
+
@fields = @collection.empty? ? [] : active_record_fields_for_object(@collection.first)
|
119
|
+
end
|
120
|
+
|
121
|
+
protected
|
122
|
+
|
76
123
|
def detect_string_method(association)
|
77
124
|
@@association_methods.detect { |method| association.respond_to?(method) }
|
78
125
|
end
|
79
126
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
127
|
+
|
128
|
+
def active_record_fields_for_object(obj)
|
129
|
+
# normal content columns
|
130
|
+
fields = obj.class.content_columns.map(&:name)
|
131
|
+
|
132
|
+
# active record associations
|
133
|
+
associations = obj.class.reflect_on_all_associations(:belongs_to) if obj.class.respond_to?(:reflect_on_all_associations)
|
134
|
+
if associations
|
135
|
+
associations = associations.map(&:name)
|
136
|
+
fields += associations
|
137
|
+
end
|
138
|
+
|
139
|
+
# remove utility columns by default
|
140
|
+
fields -= %w[created_at updated_at created_on updated_on lock_version version]
|
141
|
+
fields = fields.map(&:to_sym)
|
84
142
|
end
|
85
143
|
|
86
|
-
def
|
87
|
-
|
88
|
-
|
89
|
-
@fields = []
|
144
|
+
def send_or_call(object, duck)
|
145
|
+
if duck.is_a?(Proc)
|
146
|
+
duck.call(object)
|
90
147
|
else
|
91
|
-
object
|
92
|
-
associations = object.class.reflect_on_all_associations(:belongs_to) if object.class.respond_to?(:reflect_on_all_associations)
|
93
|
-
@fields = object.class.content_columns.map(&:name)
|
94
|
-
if associations
|
95
|
-
associations = associations.map(&:name)
|
96
|
-
@fields += associations
|
97
|
-
end
|
98
|
-
@fields -= %w[created_at updated_at created_on updated_on lock_version version]
|
99
|
-
@fields.map!(&:to_sym)
|
148
|
+
object.send(duck)
|
100
149
|
end
|
101
150
|
end
|
102
151
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
@template.content_tag(name, content, options, escape, &block)
|
107
|
-
end
|
152
|
+
def content_tag(name, content = nil, options = nil, escape = true, &block)
|
153
|
+
@template.content_tag(name, content, options, escape, &block)
|
154
|
+
end
|
108
155
|
end
|
109
156
|
end
|
data/spec/tabletastic_spec.rb
CHANGED
@@ -20,15 +20,15 @@ describe "Tabletastic#table_for" do
|
|
20
20
|
output_buffer.should have_tag("table")
|
21
21
|
end
|
22
22
|
|
23
|
-
context "
|
23
|
+
context "head and table body" do
|
24
24
|
before do
|
25
25
|
table_for([]) do |t|
|
26
|
-
concat(t.
|
26
|
+
concat(t.head)
|
27
27
|
concat(t.body)
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
31
|
-
it "should build a basic table and
|
31
|
+
it "should build a basic table and head" do
|
32
32
|
output_buffer.should have_table_with_tag("thead")
|
33
33
|
end
|
34
34
|
|
@@ -69,7 +69,7 @@ describe "Tabletastic#table_for" do
|
|
69
69
|
output_buffer.should have_tag("table#posts")
|
70
70
|
end
|
71
71
|
|
72
|
-
it "should output
|
72
|
+
it "should output head" do
|
73
73
|
output_buffer.should have_table_with_tag("thead")
|
74
74
|
end
|
75
75
|
|
@@ -162,6 +162,49 @@ describe "Tabletastic#table_for" do
|
|
162
162
|
end
|
163
163
|
end
|
164
164
|
|
165
|
+
context "with custom cell options" do
|
166
|
+
before do
|
167
|
+
table_for(@posts) do |t|
|
168
|
+
t.data do
|
169
|
+
concat(t.cell(:title, :heading => "FooBar"))
|
170
|
+
concat(t.cell(:body, :cell_html => {:class => "batquux"}))
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
it "should change the heading label for :heading option" do
|
176
|
+
output_buffer.should have_table_with_tag("th", "FooBar")
|
177
|
+
output_buffer.should have_table_with_tag("th", "Body")
|
178
|
+
end
|
179
|
+
|
180
|
+
it "should pass :cell_html to the cell" do
|
181
|
+
output_buffer.should have_table_with_tag("td.batquux")
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
context "with custom cell options" do
|
186
|
+
before do
|
187
|
+
table_for(@posts) do |t|
|
188
|
+
t.data do
|
189
|
+
concat(t.cell(:title) {|p| link_to p.title, "/" })
|
190
|
+
concat(t.cell(:body, :heading => "Content") {|p| p.body })
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
it "accepts a block as a lazy attribute" do
|
196
|
+
output_buffer.should have_table_with_tag("th:nth-child(1)", "Title")
|
197
|
+
output_buffer.should have_table_with_tag("td:nth-child(1)") do |td|
|
198
|
+
td.should have_tag("a", "The title of the post")
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
it "accepts a block as a lazy attribute (2)" do
|
203
|
+
output_buffer.should have_table_with_tag("th:nth-child(2)", "Content")
|
204
|
+
output_buffer.should have_table_with_tag("td:nth-child(2)", "Lorem ipsum")
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
165
208
|
context "and normal/association columns" do
|
166
209
|
before do
|
167
210
|
::Post.stub!(:reflect_on_all_associations).with(:belongs_to).and_return([@mock_reflection_belongs_to_author])
|
data/tabletastic.gemspec
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{tabletastic}
|
8
|
+
s.version = "0.1.0"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Joshua Davey"]
|
12
|
+
s.date = %q{2009-11-17}
|
13
|
+
s.description = %q{A table builder for active record collections that produces semantically rich and accessible markup}
|
14
|
+
s.email = %q{josh@joshuadavey.com}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"LICENSE",
|
17
|
+
"README.rdoc"
|
18
|
+
]
|
19
|
+
s.files = [
|
20
|
+
".document",
|
21
|
+
".gitignore",
|
22
|
+
"LICENSE",
|
23
|
+
"README.rdoc",
|
24
|
+
"Rakefile",
|
25
|
+
"VERSION",
|
26
|
+
"lib/tabletastic.rb",
|
27
|
+
"rails/init.rb",
|
28
|
+
"spec/spec.opts",
|
29
|
+
"spec/spec_helper.rb",
|
30
|
+
"spec/tabletastic_spec.rb",
|
31
|
+
"tabletastic.gemspec"
|
32
|
+
]
|
33
|
+
s.homepage = %q{http://github.com/jgdavey/tabletastic}
|
34
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
35
|
+
s.require_paths = ["lib"]
|
36
|
+
s.rubygems_version = %q{1.3.5}
|
37
|
+
s.summary = %q{A smarter table builder for Rails collections}
|
38
|
+
s.test_files = [
|
39
|
+
"spec/spec_helper.rb",
|
40
|
+
"spec/tabletastic_spec.rb"
|
41
|
+
]
|
42
|
+
|
43
|
+
if s.respond_to? :specification_version then
|
44
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
45
|
+
s.specification_version = 3
|
46
|
+
|
47
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
48
|
+
s.add_development_dependency(%q<rspec>, [">= 1.2.9"])
|
49
|
+
else
|
50
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
51
|
+
end
|
52
|
+
else
|
53
|
+
s.add_dependency(%q<rspec>, [">= 1.2.9"])
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: tabletastic
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Joshua Davey
|
@@ -9,7 +9,7 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
|
12
|
-
date: 2009-11-
|
12
|
+
date: 2009-11-17 00:00:00 -06:00
|
13
13
|
default_executable:
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
@@ -43,6 +43,7 @@ files:
|
|
43
43
|
- spec/spec.opts
|
44
44
|
- spec/spec_helper.rb
|
45
45
|
- spec/tabletastic_spec.rb
|
46
|
+
- tabletastic.gemspec
|
46
47
|
has_rdoc: true
|
47
48
|
homepage: http://github.com/jgdavey/tabletastic
|
48
49
|
licenses: []
|