require 'curb' require 'fastercsv' require 'table_fu' require 'net/http' module TableSetter class Table # The +Table+ class handles processing the yaml processing and csv loading, # through table fu attr_reader :data, :table_opts, :facets, :prev_page, :next_page, :page # A new Table should accept a slug, mapped to a yaml in the tables directory, # optionally you can defer loading of the table until you're ready to render it. def initialize(slug, opts={:defer => false}) options = indifferent_access YAML.load_file(Table.table_path(slug)) @table_opts = options[:table] @table_opts[:slug] = slug @deferred = opts[:defer] if !@deferred self.load end end # The load method handles the actual request either to the file system or remote url. # It performs the requested data manipulations form the yml file after the data has been loaded. # We're keeping this explicit to control against unnecessary http requests. def load csv = csv_data @data = TableFu.new(csv_data, @table_opts[:column_options] || {}) if @table_opts[:faceting] @data.col_opts[:ignored] = [@table_opts[:faceting][:facet_by]] @facets = @data.faceted_by @table_opts[:faceting][:facet_by] end @data.delete_rows! @table_opts[:dead_rows] if @table_opts[:dead_rows] end # The csv_data for the table fu instance is loaded either from the remote source or from a local # file, depending on the keys present in the yaml file. def csv_data case when google_key || url then Curl::Easy.perform(uri).body_str when file then File.open(uri).read end end # Returns a usable uri based on what sort of input we have. def uri case when google_key then "http://spreadsheets.google.com/pub?key=#{google_key}&output=csv" when url then url when file then File.expand_path("#{TableSetter.table_path}#{file}") end end # The real +updated_at+ of a Table instance is the newer modification time of the csv file or # the yaml file. Updates to either resource should break the cache. def updated_at csv_time = google_key.nil? ? modification_time(uri) : google_modification_time (csv_time > yaml_time ? csv_time : yaml_time).to_s end def faceted? !@facets.nil? end # A table isn't sortable by tablesorter if it's either faceted or multi-page paginated. def sortable? !faceted? && !hard_paginate? end # hard_paginate instructs the app to render batches of a table. def hard_paginate? @table_opts[:hard_paginate] == true end # The number of rows per page. Defaults to 20 def per_page @table_opts[:per_page] || 20 end # paginate uses TableFu's only! method to batch the table. It also computes the page attributes # which are nil and meaningless otherwise. def paginate!(curr_page) return if !hard_paginate? @page = curr_page.to_i raise ArgumentError if @page < 1 || @page > total_pages adj_page = @page - 1 > 0 ? @page - 1 : 0 @prev_page = adj_page > 0 ? adj_page : nil @next_page = page < total_pages ? (@page + 1) : nil @data.only!(adj_page * per_page..(@page * per_page - 1)) end # The total pages we'll have. We need to calculate it before paginate, so that we still have the # full @data.rows.length def total_pages @total_pages ||= (@data.rows.length / per_page.to_f).ceil end # A convienence method to return the sort array for table setter. def sort_array @data.sorted_by.inject([]) do |memo, (key, value)| memo << [@data.columns.index(key), value == 'descending' ? 0 : 1] end end # We magically need access to the top level keys like google_key, or uri for the other methods. # It's a bit dangerous because everything returns nil otherwise. At some point we should eval # and create methods at boot time. def method_missing(method) if @table_opts[method] @table_opts[method] end end private # Returns the google modification time of the spreadsheet. The public urls don't set the # last-modified header on anything, so we have to do a little dance to find out when exactly # the spreadsheet was last modified. The od[0-9] part of the feed url changes at whim, so we'll # need to keep an eye on it. Another propblem is that curb doesn't feel like parsing headers, so # since a head request from google is pretty lightweight we can get away with using Net:HTTP. # If for whatever reason the google modification time is busted we'll return the epoch, # and rely on the yaml modified time. def google_modification_time local_url = URI.parse "http://spreadsheets.google.com/feeds/list/#{google_key}/od7/public/basic" web_modification_time local_url end # Returns the last-modified time from the remote server. Assumes the remote server knows how to # do this. Returns the epoch if the remote is dense. def web_modification_time(local_url) resp = nil Net::HTTP.start(local_url.host, 80) do |http| resp = http.head(local_url.path) end resp['Last-Modified'].nil? ? Time.at(0) : Time.parse(resp['Last-Modified']) end # Dispatches to web_modification_time if we're dealing with a url, otherwise just stats the # local file. def modification_time(path) is_uri = URI.parse(path) if !is_uri.host.nil? return web_modification_time is_uri end File.new(path).mtime end # The modification time of this Table's yaml file. def yaml_time modification_time(Table.table_path(slug)) end # Enable string or symbol key access to col_opts # from sinatra. def indifferent_access(params) params = indifferent_hash.merge(params) params.each do |key, value| next unless value.is_a?(Hash) params[key] = indifferent_access(value) end end # Duplicate a hash's keys and convert them into symbols. def indifferent_hash Hash.new {|hash,key| hash[key.to_s] if Symbol === key } end public class << self # Returns all the tables in the table directory. Each table is deferred so accessing the @data # attribute will throw and error. def all tables=[] Dir.glob("#{TableSetter.table_path}/*.yml").each do |file| table = new(File.basename(file, ".yml"), :defer => true) tables << table if table.live end tables end # +fresh_yaml_time+ checks each file in the tables directory and returns the newest file's # modification time -- there's probably a more unix-y way to do this but for now this is # plenty speedy. def fresh_yaml_time newest_file = Dir["#{TableSetter.table_path}/*.yml"].inject do |memo, obj| memo_time = File.new(File.expand_path memo).mtime obj_time = File.new(File.expand_path obj).mtime if memo_time > obj_time memo else obj end end File.new(newest_file).mtime end # Convenience method for looking up by slug. def table_path(slug) "#{TableSetter.table_path}#{slug}.yml" end # Does a table with this slug exist? def exists?(slug) File.exists? table_path(slug) end end end end class TableFu::Formatting class << self # In order to show a sideways bar chart, we're extending the builtin TableFu formatters. def bar(percent) percent = percent.to_f if percent < 1 percent = percent * 100 end "
#{percent}%
" end end end