# # mingplot - a tool to plot a chart with ming. # # Copyright (C) 2003 Satoru Takabayashi # All rights reserved. # This is free software with ABSOLUTELY NO WARRANTY. # # You can redistribute it and/or modify it under the terms of # the GNU General Public License version 2. # require 'iconv' require 'getoptlong' require 'ftools' require 'time' require 'mingchart' require 'cgi' class Iconv def self.u8toeuc (str) iconv = Iconv.new("EUC-JP", "UTF-8") out = "" begin out << iconv.iconv(str) rescue Iconv::IllegalSequence => e out << e.success ch, str = e.failed.split(//u, 2) out << if respond_to?(:unknown_unicode_handler) u = ch.unpack('U').first unknown_unicode_handler(u) else "?" end retry end return out end def self.euctou8 (str) iconv = Iconv.new("UTF-8", "EUC-JP") iconv.iconv(str) end end def Iconv.unknown_unicode_handler (u) sprintf("&#x%04x;", u) end module Commify module_function def commify (number) numstr = number.to_s true while numstr.sub!(/^([-+]?\d+)(\d{3})/, '\1,\2') return numstr end end class SimpleHtmlGenerator def doctype '' end def method_missing (symbol, *args) element = symbol.to_s if block_given? if args.empty? "<#{element}>" + yield + "" else "<#{element} " + args.first.map {|key, value| "#{key}='#{value}'"}.join(" ") + ">" + yield + "" end else if args.empty? "<#{element} />" else "<#{element} " + args.first.map {|key, value| "#{key}='#{value}'"}.join(" ") + " />" end end end end module Process def exist? (pid) begin Process.kill(0, pid) true rescue false end end module_function :exist? end module MingPlot VERSION = 0.3 end class MingPlotInfoBase def count raise "must be implemented" end def key raise "must be implemented" end def title raise "must be implemented" end def to_html raise "must be implemented" end def search_url raise "must be implemented" end end class MingPlotStatBase include Enumerable include Commify def initialize (key, config ={}) @key = key @output_dir = config[:output_dir] @base_url = config[:base_url] @flash_font = config[:flash_font] @stat_file_name = File.join(@output_dir, CGI.escape(key) + ".dat") if File.exist?(@stat_file_name) time = File.mtime(@stat_file_name) @data = read(@stat_file_name) else time = Time.now @data = [] end @chart_file_name = generate_chart_file_name(time) end attr_reader :key attr_reader :flash_font attr_reader :chart_file_name private def generate_chart_file_name (time) File.join(@output_dir, CGI.escape(key) + time.to_i.to_s + ".swf") end def deserialize (data) Marshal.load(data.unpack('m').first.chomp) end def serialize (data) [Marshal.dump(data)].pack('m').gsub(/\n/,"") end def read (file_name) return [] unless File.exist?(file_name) stat = Array.new File.new(file_name).readlines.each {|line| begin time, data = line.split if /^\d\d\d\d-\d\d-\d\d$/.match(time) # old format time = Time.parse(time).to_i info = data.to_i else time = time.to_i info = deserialize(data) end rescue => e next end stat.push([time, info]) if time and info } return stat end def chart_html_internal (info, chart_url) width = 300 height = width / Math.golden_ratio codebase = "http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=5,0,0,0" pluginspace = "http://www.macromedia.com/shockwave/download/index.cgi?P1_Prod_Version=ShockwaveFlash" g = SimpleHtmlGenerator.new g.div(:class => "mingplot") { g.h2(:class => "mingplot") { g.a(:href => "#" + CGI.escape(info.key)) { ">>" } + " " + g.a(:href => info.search_url, :name => CGI.escape(info.key)) { CGI.escapeHTML(info.title) } } + info.to_html + g.p(:class => "chart_flash") { g.object(:classid => "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000", :codebase => codebase, :id => "mingplot", :width => width, :height => height) { g.param(:name => "movie", :value => chart_url) + g.param(:name => "quality", :value => "high") + g.param(:name => "play", :value => "true") + g.param(:name => "menu", :value => "true") + g.param(:name => "loop", :value => "false") + g.embed(:name => "mingplot", :src => chart_url, :width => width, :height => height, :pluginspace => pluginspace, :type => "application/x-shockwave-flash") {""} } + g.br + g.p(:class => "chart_caption") { g.a(:href => chart_url) { "full-sized chart" } + " | " + g.strong { g.a(:href => latest_info.search_url) { service_name + " now!" } } + " | " + commify(latest_info.count) + " " + diff_summary_html } } } end def update_chart_file_name time = File.mtime(@stat_file_name) @chart_file_name = generate_chart_file_name(time) end public def each @data.each {|x| yield(x) } end def diff_summary_html if latest_info and latest_info.count and prev_info and prev_info.count g = SimpleHtmlGenerator.new diff = latest_info.count - prev_info.count if diff > 0 " (" + g.strong(:class => "plus") { "+" + commify(diff) } + ")" elsif diff < 0 " (" + g.strong(:class => "minus") { commify(diff) } + ")" else "" end else "" end end def update File.mkpath(@output_dir) time = Time.now.to_i info = get_info if info.nil? return false else @data.push([time, info]) File.open(@stat_file_name, "a") {|f| f.printf("%d\t%s\n", time, serialize(info)) } sleep 1 return true end end def chart_exist? File.exist?(@chart_file_name) end def prev_info if @data[-2] @data[-2].last else nil end end def latest_info if @data.last @data.last.last else nil end end def chart_title latest_info.title end def get_info raise "must be implemented" end def service_name raise "must be implemented" end def draw_chart_internal raise "must be implemented" end def draw_chart update_chart_file_name draw_chart_internal(@data, @chart_file_name) end def remove_old_charts chart_file_names = Dir.glob(File.join(@output_dir, @key + "*" + "*.swf")) chart_file_names.each {|file_name| unless File.basename(file_name) == File.basename(@chart_file_name) File.unlink(file_name) end } end def chart_html () chart_url = if @base_url File.join(@base_url, CGI.escape(File.basename(@chart_file_name))) else CGI.escape(File.basename(@chart_file_name)) end chart_html_internal(latest_info, chart_url) end end class MingPlotIndex include Commify def initialize (stats, program_name, config) raise if config[:output_dir].nil? raise if config[:flash_font].nil? @stats = stats @program_name = program_name @output_dir = config[:output_dir] @css_file_name = (config[:css] or "mingplot.css") @html_file_name = File.join(@output_dir, "index.html") @mingplot_url = "http://namazu.org/~satoru/mingplot/" end public def generate_key_links (g) g.ul { @stats.map {|stat| g.li { g.a(:href => "#" + CGI.escape(stat.latest_info.key)) { CGI.escapeHTML(stat.latest_info.title) } + ": " + commify(stat.latest_info.count) + " " + stat.diff_summary_html } }.join } end def update_html g = SimpleHtmlGenerator.new title = @program_name + ": " + if @stats.length <= 2 @stats.map {|stat| CGI.escapeHTML(stat.latest_info.title) }.join(", ") else @stats[0,2].map {|stat| CGI.escapeHTML(stat.latest_info.title) }.join(", ") + ", etc." end html = g.doctype + g.html { g.head { g.meta("http-equiv".intern => "content-type", :content => "text/html", :charset => "utf-8") + g.link(:rel => "stylesheet", :href => File.basename(@css_file_name), :media => "all") + g.title { title } } + g.body { g.h1 { title } + g.p(:class => "date") { "Last Updated: " + Time.now.to_s } + g.hr + generate_key_links(g) + g.hr + @stats.map {|stat| stat.chart_html }.join(g.hr) + g.hr + g.p(:class => "footer") { "Generated by " + g.a(:href => @mingplot_url) { "mingplot" } + "." } } } File.open(@html_file_name, "w") {|f| f.print(html) } end def update_css File.copy(@css_file_name, @output_dir) end public def update File.mkpath(@output_dir) update_html update_css end end class Logger def initialize (log_file) @log_file = log_file @log_file.sync = true end private def puts_log (msg) time = Time.now.strftime("%Y-%m-%dT%H:%M:%S") @log_file.puts "#{time}: #{msg}" end public def log (msg) puts_log(msg) end end class MingPlotApplication def initialize (stat_class, data_dir) @stat_class = stat_class @default_config = Hash.new @program_name = File.basename($0) @default_config[:token] = nil @default_config[:pid_file_name] = ENV['HOME'] + "/.#{@program_name}.pid" @default_config[:log_file_name] = ENV['HOME'] + "/.#{@program_name}.log" @default_config[:charset] = "euc-jp" @default_config[:output_dir] = File.expand_path("#{@program_name}-out") @default_config[:flash_font] = File.join(data_dir, "EfontSerifB.fdb") @default_config[:css] = File.join(data_dir, "mingplot.css") @default_config[:interval] = 60 * 60 * 24 @default_config[:daemon_p] = false @default_config[:stop_p] = false @default_config[:quiet_p] = false @default_config[:file] = nil @default_config[:id] = nil @default_config[:locale] = "jp" end private def pf (format, *args) printf(format + "\n", *args) end def show_usage config = @default_config pf("Usage: #{@program_name} [OPTION] [KEY]...") pf(" -h, --help show this help message") pf(" -v, --version print version information and exit") pf(" -k, --token=TOKEN use TOKEN") pf(" -c, --charset=CHARSET use CHARSET as the charaset for KEY [%s]", config[:charset]) pf(" -d, --daemon=[N] run as a daemon once per N seconds [%d]", config[:interval]) pf(" --stop stop a daemon") pf(" -q --quiet suppress all normal output") pf(" -o, --output-dir=DIR output files to DIR [%s]", config[:output_dir]) pf(" -f, --file=FILE obtain KEY from FILE") pf(" --update=DIR update cfiles in DIR") pf(" --flash-font=FONT use FONT for Flash [%s]", config[:flash_font]) pf(" --css=FILE use FILE as a CSS [%s]", config[:css]) pf(" --id=ID use ID (for associate program)") pf(" --locale=LOCALE use LOCALE [%s]", config[:locale]) exit end def show_version puts "#{@program_name} #{MingPlot::VERSION}" exit end def parse_options options = Hash.new parser = GetoptLong.new parser.set_options(['--help', '-h', GetoptLong::NO_ARGUMENT], ['--version', '-v', GetoptLong::NO_ARGUMENT], ['--daemon', '-d', GetoptLong::OPTIONAL_ARGUMENT], ['--stop', GetoptLong::NO_ARGUMENT], ['--quiet', '-q', GetoptLong::NO_ARGUMENT], ['--update', GetoptLong::REQUIRED_ARGUMENT], ['--token', '-k', GetoptLong::REQUIRED_ARGUMENT], ['--host', GetoptLong::REQUIRED_ARGUMENT], ['--flash-font', GetoptLong::REQUIRED_ARGUMENT], ['--file', '-f', GetoptLong::REQUIRED_ARGUMENT], ['--css', GetoptLong::REQUIRED_ARGUMENT], ['--output-dir', '-o', GetoptLong::REQUIRED_ARGUMENT], ['--interval', '-i', GetoptLong::REQUIRED_ARGUMENT], ['--id', GetoptLong::REQUIRED_ARGUMENT], ['--locale', GetoptLong::REQUIRED_ARGUMENT], ['--charset', '-c', GetoptLong::REQUIRED_ARGUMENT]) parser.each_option {|name, arg| options[name.sub(/^--/, "")] = arg } config = @default_config config[:host] = options['host'] if options['host'] config[:charset] = options['charset'] if options['charset'] if options['daemon'] config[:daemon_p] = options['daemon'] config[:quiet_p] = true end config[:interval] = options['daemon'].to_i if options['daemon'] and !options['daemon'].empty? config[:stop_p] = options['stop'] if options['stop'] config[:quiet_p] = options['quiet'] if options['quiet'] config[:token] = options['token'] if options['token'] config[:output_dir] = File.expand_path(options['output-dir']) if options['output-dir'] config[:flash_font] = File.expand_path(options['flash-font']) if options['flash-font'] config[:css] = File.expand_path(options['css']) if options['css'] config[:file] = File.expand_path(options['file']) if options['file'] config[:id] = options['id'] if options['id'] config[:locale] = options['locale'] if options['locale'] if options['update'] config[:update] = options['update'] config[:output_dir] = options['update'] end stop_daemon(config[:pid_file_name]) if config[:stop_p] show_version if options['version'] show_usage if options['help'] or (!config[:file] and !config[:update] and ARGV.length == 0) return config end def read_pid_file (pid_file_name) begin File.open(pid_file_name) {|f| f.readline.chomp.to_i } rescue nil end end def write_pid_file (pid_file_name) File.open(pid_file_name, "w") {|f| f.puts Process.pid } end def be_daemon (pid_file_name) if File.exist?(pid_file_name) pid = read_pid_file(pid_file_name) if Process.exist?(pid) STDERR.puts "#{@program_name} daemon already running" exit(1) end end exit!(0) if fork Process::setsid exit!(0) if fork Dir::chdir("/") File::umask(022) STDIN.close STDOUT.close STDERR.close write_pid_file(pid_file_name) end def stop_daemon (pid_file_name) pid = read_pid_file(pid_file_name) if pid and Process.exist?(pid) Process.kill("TERM", pid) else puts "no process is killed" end File.unlink(pid_file_name) if File.exist?(pid_file_name) exit end def get_keys (config) if config[:file] keys = File.new(config[:file]).readlines.map {|line| line.chomp }.find_all {|key| !key.strip.empty? } elsif config[:update] keys = Dir.glob(File.join(config[:update], "*.dat")).map {|file_name| File.basename(file_name).chomp(".dat") } else keys = ARGV end end def convert_key (key, charset) if charset codeconv = Iconv.new("utf-8", charset) codeconv.iconv(key) else key end end def update (config) stats = [] get_keys(config).each {|original_key| key = convert_key(original_key, config[:charset]) stat = @stat_class.new(key, config) if stat.update stat.draw_chart stat.remove_old_charts stats.push(stat) unless config[:quiet_p] STDERR.printf("%s: %s\n", original_key, stat.latest_info.count) end else STDERR.printf("%s: not found\n", original_key) end } mingplot = MingPlotIndex.new(stats, @program_name, config) mingplot.update end public def start config = parse_options if config[:daemon_p] logger = Logger.new(File.open(config[:log_file_name], "a")) be_daemon(config[:pid_file_name]) loop { begin update(config) logger.log(sprintf("Keys: %s", keys.join(" "))) rescue => e logger.log(sprintf("%s\n%s", e.message, e.backtrace)) end sleep(config[:interval]) } else update(config) end end end