table_fu 0.1.1
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.
- data/.gitignore +4 -0
- data/LICENSE +20 -0
- data/README +37 -0
- data/Rakefile +58 -0
- data/VERSION.yml +5 -0
- data/doc/TableFu/Datum.html +743 -0
- data/doc/TableFu/Formatting.html +469 -0
- data/doc/TableFu/Header.html +198 -0
- data/doc/TableFu/Row.html +508 -0
- data/doc/TableFu.html +1806 -0
- data/doc/_index.html +154 -0
- data/doc/class_list.html +36 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +50 -0
- data/doc/css/style.css +268 -0
- data/doc/file.README.html +89 -0
- data/doc/file_list.html +38 -0
- data/doc/frames.html +13 -0
- data/doc/index.html +89 -0
- data/doc/js/app.js +99 -0
- data/doc/js/full_list.js +106 -0
- data/doc/js/jquery.js +19 -0
- data/doc/method_list.html +363 -0
- data/doc/top-level-namespace.html +85 -0
- data/documentation/css/dawn.css +121 -0
- data/documentation/css/styles.css +63 -0
- data/documentation/images/proplogo.png +0 -0
- data/documentation/index.html.erb +148 -0
- data/examples/columns.rb +7 -0
- data/examples/columns_hidden.rb +5 -0
- data/examples/faceting.rb +2 -0
- data/examples/formatting_options.rb +6 -0
- data/examples/last_name.rb +4 -0
- data/examples/link.rb +11 -0
- data/examples/only.rb +6 -0
- data/examples/rails_helpers.rb +3 -0
- data/examples/sort_by_column.rb +11 -0
- data/examples/sort_by_number.rb +0 -0
- data/examples/totals.rb +2 -0
- data/examples/zap_joyce.rb +3 -0
- data/index.html +210 -0
- data/lib/table_fu/formatting.rb +52 -0
- data/lib/table_fu.rb +386 -0
- data/spec/assets/sample.csv +476 -0
- data/spec/assets/test.csv +8 -0
- data/spec/assets/test_macro.csv +8 -0
- data/spec/rcov.opts +2 -0
- data/spec/readme_example_spec.rb +39 -0
- data/spec/spec.opts +4 -0
- data/spec/spec_helper.rb +4 -0
- data/spec/table_fu_spec.rb +221 -0
- data/table_fu.gemspec +112 -0
- metadata +141 -0
data/lib/table_fu.rb
ADDED
@@ -0,0 +1,386 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'fastercsv'
|
3
|
+
|
4
|
+
|
5
|
+
# TableFu turns a matric array(from a csv file for example) into a spreadsheet.
|
6
|
+
#
|
7
|
+
# Allows formatting, macros, sorting, and faceting.
|
8
|
+
#
|
9
|
+
# Documentation:
|
10
|
+
# http://propublica.github.com/table-fu
|
11
|
+
class TableFu
|
12
|
+
|
13
|
+
attr_reader :deleted_rows, :table, :totals, :column_headers
|
14
|
+
attr_accessor :faceted_on, :col_opts
|
15
|
+
|
16
|
+
# Should be initialized with a matrix array or a string containing a csv, and expects the first
|
17
|
+
# array in the matrix to be column headers.
|
18
|
+
def initialize(table, column_opts = {})
|
19
|
+
# Assume if we're past a string or filehandle we need to parse a csv
|
20
|
+
if table.is_a?(String) || table.is_a?(File)
|
21
|
+
table = FasterCSV.parse(table)
|
22
|
+
end
|
23
|
+
@column_headers = table.slice!(0)
|
24
|
+
@totals = {}
|
25
|
+
@table = table
|
26
|
+
@col_opts = column_opts
|
27
|
+
yield self if block_given?
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
|
32
|
+
# Pass it an array and it will delete it from the table, but save the data in
|
33
|
+
# @deleted_rows@ for later perusal.
|
34
|
+
#
|
35
|
+
# Returns:
|
36
|
+
# nothing
|
37
|
+
def delete_rows!(arr)
|
38
|
+
@deleted_rows ||= []
|
39
|
+
arr.map do |item|
|
40
|
+
@deleted_rows << @table[item] #account for header and 0 index
|
41
|
+
@table[item] = nil
|
42
|
+
end
|
43
|
+
@table.compact!
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
# Inverse slice: Only keep the rows in the range after sorting
|
48
|
+
def only!(range)
|
49
|
+
rows_to_exclude = rows.map do |row|
|
50
|
+
range.include?(row.row_num) ? nil : row.row_num
|
51
|
+
end
|
52
|
+
delete_rows!(rows_to_exclude.compact)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Returns a Row object for the row at a certain index
|
56
|
+
def row_at(row_num)
|
57
|
+
TableFu::Row.new(@table[row_num], row_num, self)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns all the Row objects for this object as a collection
|
61
|
+
def rows
|
62
|
+
all_rows = []
|
63
|
+
@table.each_with_index do |row, index|
|
64
|
+
all_rows << TableFu::Row.new(row, index, self)
|
65
|
+
end
|
66
|
+
all_rows.sort
|
67
|
+
end
|
68
|
+
|
69
|
+
# Return the headers defined in column headers or cherry picked from @col_opts
|
70
|
+
def columns
|
71
|
+
@col_opts[:columns] || column_headers
|
72
|
+
end
|
73
|
+
|
74
|
+
# Return the headers of the array
|
75
|
+
def headers
|
76
|
+
all_columns = []
|
77
|
+
columns.each do |header|
|
78
|
+
all_columns << TableFu::Header.new(header, header, nil, self)
|
79
|
+
end
|
80
|
+
all_columns
|
81
|
+
end
|
82
|
+
|
83
|
+
# Sum the values of a particular column
|
84
|
+
def sum_totals_for(column)
|
85
|
+
@totals[column.to_s] = rows.inject(0) { |sum, r| to_numeric(r.datum_for(column).value) + sum }
|
86
|
+
end
|
87
|
+
|
88
|
+
# Sum the values of a particular column and return a Datum
|
89
|
+
def total_for(column)
|
90
|
+
sum_totals_for(column)
|
91
|
+
Datum.new(@totals[column.to_s], column, nil, self)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Return an array of TableFu instances grouped by a column.
|
95
|
+
def faceted_by(column, opts = {})
|
96
|
+
faceted_spreadsheets = {}
|
97
|
+
rows.each do |row|
|
98
|
+
unless row.column_for(column).value.nil?
|
99
|
+
faceted_spreadsheets[row.column_for(column).value] ||= []
|
100
|
+
faceted_spreadsheets[row.column_for(column).value] << row
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Create new table_fu instances for each facet
|
105
|
+
tables = []
|
106
|
+
faceted_spreadsheets.each do |key,value|
|
107
|
+
new_table = [@column_headers] + value
|
108
|
+
table = TableFu.new(new_table)
|
109
|
+
table.faceted_on = key
|
110
|
+
table.col_opts = @col_opts #formatting should be carried through
|
111
|
+
tables << table
|
112
|
+
end
|
113
|
+
|
114
|
+
tables.sort! do |a,b|
|
115
|
+
a.faceted_on <=> b.faceted_on
|
116
|
+
end
|
117
|
+
|
118
|
+
if opts[:total]
|
119
|
+
opts[:total].each do |c|
|
120
|
+
tables.each do |f|
|
121
|
+
f.sum_totals_for(c)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
tables
|
127
|
+
end
|
128
|
+
|
129
|
+
# Return a numeric instance for a string number, or if it's a string we
|
130
|
+
# return 1, this way if we total up a series of strings it's a count
|
131
|
+
def to_numeric(num)
|
132
|
+
if num.nil?
|
133
|
+
0
|
134
|
+
elsif num.kind_of? Integer
|
135
|
+
num
|
136
|
+
else
|
137
|
+
1 # We count each instance of a string this way
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Return true if this table is faceted
|
142
|
+
def faceted?
|
143
|
+
not faceted_on.nil?
|
144
|
+
end
|
145
|
+
|
146
|
+
# Return the sorted_by column
|
147
|
+
def sorted_by
|
148
|
+
@col_opts[:sorted_by]
|
149
|
+
end
|
150
|
+
|
151
|
+
# Set the sorted_by column
|
152
|
+
def sorted_by=(header)
|
153
|
+
@col_opts[:sorted_by] = header
|
154
|
+
end
|
155
|
+
|
156
|
+
# Return the formatting hash
|
157
|
+
def formatting
|
158
|
+
@col_opts[:formatting]
|
159
|
+
end
|
160
|
+
|
161
|
+
# Set the formatting hash
|
162
|
+
def formatting=(headers)
|
163
|
+
@col_opts[:formatting] = headers
|
164
|
+
end
|
165
|
+
|
166
|
+
# Set up the cherry picked columns
|
167
|
+
def columns=(array)
|
168
|
+
@col_opts[:columns] = array
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
|
173
|
+
end
|
174
|
+
|
175
|
+
class TableFu
|
176
|
+
# TableFu::Row adds functionality to an row array in a TableFu instance
|
177
|
+
class Row < Array
|
178
|
+
|
179
|
+
attr_reader :row_num
|
180
|
+
|
181
|
+
def initialize(row, row_num, spreadsheet)
|
182
|
+
self.replace row
|
183
|
+
@row_num = row_num
|
184
|
+
@spreadsheet = spreadsheet
|
185
|
+
end
|
186
|
+
|
187
|
+
def columns
|
188
|
+
all_cols = []
|
189
|
+
@spreadsheet.columns.each do |column|
|
190
|
+
all_cols << datum_for(column)
|
191
|
+
end
|
192
|
+
all_cols
|
193
|
+
end
|
194
|
+
|
195
|
+
# This returns a Datum object for a header name. Will return a nil Datum object
|
196
|
+
# for nonexistant column names
|
197
|
+
#
|
198
|
+
# Parameters:
|
199
|
+
# header name
|
200
|
+
#
|
201
|
+
# Returns:
|
202
|
+
# Datum object
|
203
|
+
#
|
204
|
+
def datum_for(col_name)
|
205
|
+
if col_num = @spreadsheet.column_headers.index(col_name)
|
206
|
+
TableFu::Datum.new(self[col_num], col_name, @row_num, @spreadsheet)
|
207
|
+
else # Return a nil Datum object for non existant column names
|
208
|
+
TableFu::Datum.new(nil, col_name, @row_num, @spreadsheet)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
alias_method :column_for, :datum_for
|
212
|
+
|
213
|
+
# Comparator for sorting a spreadsheet row.
|
214
|
+
#
|
215
|
+
def <=>(b)
|
216
|
+
if @spreadsheet.sorted_by
|
217
|
+
column = @spreadsheet.sorted_by.keys.first
|
218
|
+
order = @spreadsheet.sorted_by[@spreadsheet.sorted_by.keys.first]["order"]
|
219
|
+
format = @spreadsheet.sorted_by[@spreadsheet.sorted_by.keys.first]["format"]
|
220
|
+
a = column_for(column).value || ''
|
221
|
+
b = b.column_for(column).value || ''
|
222
|
+
if format
|
223
|
+
a = TableFu::Formatting.send(format, a) || ''
|
224
|
+
b = TableFu::Formatting.send(format, b) || ''
|
225
|
+
end
|
226
|
+
result = a <=> b
|
227
|
+
result = -1 if result.nil?
|
228
|
+
result = result * -1 if order == 'descending'
|
229
|
+
result
|
230
|
+
else
|
231
|
+
-1
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
end
|
236
|
+
# A Datum is an individual cell in the TableFu::Row
|
237
|
+
class Datum
|
238
|
+
|
239
|
+
attr_reader :options, :column_name
|
240
|
+
|
241
|
+
# Each piece of datum should know where it is by column and row number, along
|
242
|
+
# with the spreadsheet it's apart of. There's probably a better way to go
|
243
|
+
# about doing this. Subclass?
|
244
|
+
def initialize(datum, col_name, row_num, spreadsheet)
|
245
|
+
@datum = datum
|
246
|
+
@column_name = col_name
|
247
|
+
@row_num = row_num
|
248
|
+
@spreadsheet = spreadsheet
|
249
|
+
end
|
250
|
+
|
251
|
+
# Our standard formatter for the datum
|
252
|
+
#
|
253
|
+
# Returns:
|
254
|
+
# the formatted value, macro value, or a empty string
|
255
|
+
#
|
256
|
+
# First we test to see if this Datum has a macro attached to it. If so
|
257
|
+
# we let the macro method do it's magic
|
258
|
+
#
|
259
|
+
# Then we test for a simple formatter method.
|
260
|
+
#
|
261
|
+
# And finally we return a empty string object or the value.
|
262
|
+
#
|
263
|
+
def to_s
|
264
|
+
if macro_value
|
265
|
+
macro_value
|
266
|
+
elsif @spreadsheet.formatting && format_method = @spreadsheet.formatting[column_name]
|
267
|
+
TableFu::Formatting.send(format_method, @datum) || ''
|
268
|
+
else
|
269
|
+
@datum || ''
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Returns the macro'd format if there is one
|
274
|
+
#
|
275
|
+
# Returns:
|
276
|
+
# The macro value if it exists, otherwise nil
|
277
|
+
def macro_value
|
278
|
+
# Grab the macro method first
|
279
|
+
# Then get a array of the values in the columns listed as arguments
|
280
|
+
# Splat the arguments to the macro method.
|
281
|
+
# Example:
|
282
|
+
# @spreadsheet.col_opts[:formatting] =
|
283
|
+
# {'Total Appropriation' => :currency,
|
284
|
+
# 'AppendedColumn' => {'method' => 'append', 'arguments' => ['Projects','State']}}
|
285
|
+
#
|
286
|
+
# in the above case we handle the AppendedColumn in this method
|
287
|
+
if @row_num && @spreadsheet.formatting && @spreadsheet.formatting[@column_name].is_a?(Hash)
|
288
|
+
method = @spreadsheet.formatting[@column_name]['method']
|
289
|
+
arguments = @spreadsheet.formatting[@column_name]['arguments'].inject([]){|arr,arg| arr << @spreadsheet.rows[@row_num].column_for(arg); arr}
|
290
|
+
TableFu::Formatting.send(method, *arguments)
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
# Returns the raw value of a datum
|
295
|
+
#
|
296
|
+
# Returns:
|
297
|
+
# raw value of the datum, could be nil
|
298
|
+
def value
|
299
|
+
if @datum =~ /[0-9]+/
|
300
|
+
@datum.to_i
|
301
|
+
else
|
302
|
+
@datum
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# This method missing looks for 4 matches
|
307
|
+
#
|
308
|
+
# First Option
|
309
|
+
# We have a column option by that method name and it applies to this column
|
310
|
+
# Example -
|
311
|
+
# >> @data.column_name
|
312
|
+
# => 'Total'
|
313
|
+
# >> @datum.style
|
314
|
+
# Finds col_opt[:style] = {'Total' => 'text-align:left;'}
|
315
|
+
# => 'text-align:left;'
|
316
|
+
#
|
317
|
+
# Second Option
|
318
|
+
# We have a column option by that method name, but no attribute
|
319
|
+
# >> @data.column_name
|
320
|
+
# => 'Total'
|
321
|
+
# >> @datum.style
|
322
|
+
# Finds col_opt[:style] = {'State' => 'text-align:left;'}
|
323
|
+
# => ''
|
324
|
+
#
|
325
|
+
# Third Option
|
326
|
+
# The boolean
|
327
|
+
# >> @data.invisible?
|
328
|
+
# And we've set it col_opts[:invisible] = ['Total']
|
329
|
+
# => true
|
330
|
+
#
|
331
|
+
# Fourth Option
|
332
|
+
# The boolean that's false
|
333
|
+
# >> @data.invisible?
|
334
|
+
# And it's not in the list col_opts[:invisible] = ['State']
|
335
|
+
# => false
|
336
|
+
#
|
337
|
+
def method_missing(method)
|
338
|
+
opts = indifferent_access @spreadsheet.col_opts
|
339
|
+
if val = opts[method] && opts[method][column_name]
|
340
|
+
val
|
341
|
+
elsif val = opts[method] && !opts[method][column_name]
|
342
|
+
''
|
343
|
+
elsif method.to_s =~ /\?$/ && col_opts = opts[method.to_s.chop.to_sym]
|
344
|
+
col_opts.index(column_name) || false
|
345
|
+
elsif method.to_s =~ /\?$/ && !opts[method.to_s.chop.to_sym]
|
346
|
+
nil
|
347
|
+
else
|
348
|
+
super
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
private
|
353
|
+
|
354
|
+
# Enable string or symbol key access to col_opts
|
355
|
+
# from sinatra
|
356
|
+
def indifferent_access(params)
|
357
|
+
params = indifferent_hash.merge(params)
|
358
|
+
params.each do |key, value|
|
359
|
+
next unless value.is_a?(Hash)
|
360
|
+
params[key] = indifferent_access(value)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
def indifferent_hash
|
365
|
+
Hash.new {|hash,key| hash[key.to_s] if Symbol === key }
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
# A header object needs to be a special kind of Datum, and
|
370
|
+
# we may want to extend this further, but currently we just
|
371
|
+
# need to ensure that when to_s is called on a @Header@ object
|
372
|
+
# that we don't run it through a macro, or a formatter.
|
373
|
+
class Header < Datum
|
374
|
+
|
375
|
+
def to_s
|
376
|
+
@datum
|
377
|
+
end
|
378
|
+
|
379
|
+
end
|
380
|
+
|
381
|
+
end
|
382
|
+
|
383
|
+
$:.unshift(File.dirname(__FILE__)) unless
|
384
|
+
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
385
|
+
|
386
|
+
require 'table_fu/formatting'
|