#! /usr/bin/env ruby begin require 'rubygems' require 'rbosa' require 'getoptlong' require 'active_support' rescue LoadError puts "matewatch requires gems: rubyosa, and activesupport" exit end class Matewatch SLEEPWATCHER = "/usr/local/sbin/sleepwatcher" # optional DATA_DIR = File.expand_path("~/.matewatch") START = 'USR1' PAUSE = 'USR2' # COMMANDS module Commands # starts server, or sends START to running server def start if pid puts "matewatch is running (pid: #{pid}), sending START to it" if system("kill -s #{START} #{pid}") puts "matewatch #{pid} successfully started" else puts "Couldn't start process #{pid}. Perhaps matewatch isn't really running?" puts "Try removing the pid file at:\n #{DATA_DIR}/pid" end else write_pid sleepwatch @watcher.watch(@poll || 5) end end # send PAUSE to def pause if pid puts "matewatch is running (pid: #{pid}), sending PAUSE to it" if system("kill -s #{PAUSE} #{pid}") puts "matchwatch #{pid} successfully paused" else puts "Couldn't pause process #{pid}. Perhaps matewatch isn't really running?" puts "Try removing the pid file at:\n #{DATA_DIR}/pid" end else puts "matewatch is not running, start matewatch with 'matewatch start'" end end def report projects = (name = @args.shift) ? [@watcher.project(name)] : @watcher.projects heading = "#{"#{@type} " if @type}report" if @day heading += " " + @day.strftime("%a %d/%b/%y") else heading += @from ? " " + @from.strftime(@from.strftime("%a %d/%b/%y")) : "" heading += " => " heading += @to ? @to.strftime(@from.strftime("%a %d/%b/%y")) : Time.now.strftime("%a %d/%b/%y") end projects.each do |project| line = '=' * (project.name.length + heading.length + 2) puts "\n#{line}\n#{project.name}: #{heading}\n#{line}\n" puts @day ? project.day_report(@day, @type) : project.report(@from, @to, @type) puts end end def list printf("\n %-20s %s\n\n", 'NAME', 'PATH') idx = 0 @watcher.projects.each{|p| printf(" %2d. %-20s %s\n", idx += 1, p.name, p.path)} puts end def add usage = "Eh? matewatch add []" @name = @args.shift || exit_with_msg(usage) @path = @args.shift || exit_with_msg(usage) @pos = @args.first ? @args.shift.to_i : nil project = @watcher.add_project(@name, @path, @pos) puts "project #{project.name} #{project.path} added" end def remove @name = @args.shift || exit_with_msg("Eh? matewatch remove ") project = @watcher.remove_project(@name) puts "project #{project.name} removed" end def move usage = "Eh? matewatch move " @name = @args.shift || exit_with_msg(usage) @pos = (@args.first ? @args.shift.to_i : nil) || exit_with_msg(usage) project = @watcher.move_project(@name, @pos) puts "project #{project.name} moved" end end include Commands def self.data_dir returning(Matewatch::DATA_DIR) {|dir| system("mkdir -p #{dir}")} end def initialize(args) @args = args extract_options setup command = @args.shift if Commands.instance_methods.include?(command) send(command) else puts help_msg end rescue Exception => e @debug ? (raise e) : exit_with_msg($!) end protected def extract_options opts = GetoptLong.new( [ '--help', '-h', GetoptLong::NO_ARGUMENT ], [ '--require-frontmost', '-r', GetoptLong::NO_ARGUMENT], [ '--verbose', '-v', GetoptLong::NO_ARGUMENT ], [ '--debug', GetoptLong::NO_ARGUMENT ], [ '--hourly', GetoptLong::NO_ARGUMENT ], [ '--session', GetoptLong::NO_ARGUMENT ], [ '--from', '-f', GetoptLong::REQUIRED_ARGUMENT], [ '--to', '-t', GetoptLong::REQUIRED_ARGUMENT], [ '--day', '-d', GetoptLong::REQUIRED_ARGUMENT], [ '--poll', GetoptLong::REQUIRED_ARGUMENT] ) opts.each do |opt, arg| case opt when '--help' then exit_with_msg(help_msg) when '--require-frontmost' then @require_frontmost = true when '--verbose' then @verbose = true when '--from' then @from = Time.parse(arg) when '--to' then @to = Time.parse(arg) when '--day' then @day = Time.parse(arg) when '--poll' then @poll = arg.to_i when '--hourly' then @type = 'hourly' when '--session' then @type = 'session' when '--debug' then @debug = true end end end def data_dir self.class.data_dir end def setup @logger = Logger.new(@verbose) @watcher = Watcher.new :logger => @logger, :require_frontmost => @require_frontmost save_and_exit = lambda do @logger.info "\nCleaning up" @watcher.shutdown Process.detach(@sleepwatcher_pid) if @sleepwatcher_pid exit end trap 'SIGTERM', save_and_exit trap 'SIGINT', save_and_exit trap PAUSE, lambda{ @watcher.pause } trap START, lambda{ @watcher.start } end def sleepwatch if File.exist?(SLEEPWATCHER) @sleepwatcher_pid = Process.fork do @logger.info "\nsleepwatching matewatch\n\n" system "#{SLEEPWATCHER} -s 'kill -s #{PAUSE} #{pid}' -w 'kill -s #{START} #{pid}'" end else puts "\nIf you install sleepwatcher http://www.bernhard-baehr.de/sleepwatcher_2.0.4.dmg" puts "then matewatch will pause timers when going to sleep. You only need to install the" puts "sleepwatcher command line tool, no need for the startup item.\n\n" end end def pid unless @pid pid = File.read("#{Matewatch.data_dir}/pid") rescue nil pis = pid.to_i unless pid.nil? end end def write_pid File.open("#{data_dir}/pid", 'w+') {|f| f << Process.pid } end def help_msg <<-end_str matewatch watches textmate and logs how long you are working on files in spec- ified directories USAGE matewatch COMMAND [OPTIONS] matewatch start Starts the project watcher --verbose -v show output --poll= -p poll every (n) seconds --require-frontmost -r require that textmate be frontmost application to log time data matewatch report [] Show brief report of hours/minutes per day --hourly OR --session show hourly/session report --from= -f from specified date --to= -t to specified date --day= -d for specified date matewatch list List projects being watched matewatch add [] Add a project to the watch list matewatch remove Remove a project from the watch list. Copies the project data to a timestamped backup in #{Matewatch.data_dir} matewatch move Move a project up or down the list matewatch pause Will pause the current matewatcher, 'matewatch start' will restart it DATA If you want to get at the session data for your projects, you'll find them in #{Matewatch.data_dir}. The files are YAML format, and so are easily editable/exportable. by Argument from Design (c) 2007 (MIT License) end_str end def exit_with_msg(str) puts str exit end class Logger def initialize(on = true) @on = on end def info(str) puts str if @on end end class Timer def initialize @intervals = [] end # return nil if nothing changed def start @started_at ? false : @started_at = Time.now end # return nil if nothing changed def stop if @started_at returning [@started_at, Time.now] do |interval| @intervals << interval @started_at = nil end end end def intervals_beginning @intervals.first && @intervals.first.first end def intervals_end @intervals.last && @intervals.last.last end # return array of [start, stop] intervals which include the # specified period. # if to is >= the intervals end, then include the current interval def intervals(from = nil, to = nil) return [] unless intervals_beginning from ||= intervals_beginning to ||= Time.now.beginning_of_next_day intervals = [] @intervals.each do |start, stop| if from > start if stop > from stop = to if to < stop intervals.push [from, stop] end next elsif stop > to intervals.push [start, to] if start < to break end intervals.push([start, stop]) end if @started_at && @started_at < to && to == Time.now.beginning_of_next_day intervals.push([@started_at, Time.now]) end intervals end # return duration of intervals in between specified period def total(from = nil, to = nil) intervals(from, to).inject(0){|m, (start, stop)| m + (stop - start)}.to_i end # return total for a particular day def day_total(day = nil) day = day.to_time rescue Time.now total day.beginning_of_day, day.beginning_of_next_day end # return total for a particular hour def hour_total(hour, day = nil, seconds = false) day = (day.to_time rescue Time.now).beginning_of_day hour = day + hour.hours total hour, hour + 1.hour end end class Project attr_reader :timer, :name, :path attr_writer :logger def self.load(name) YAML.load_file(Matewatch.data_dir + "/#{name}.yml") rescue nil end def initialize(name, path) @name = name @path = File.expand_path(path) @timer = Timer.new end def logger @logger ||= Logger.new end def stop if timer.stop logger.info "[ ] #{name} stopped at #{Time.now}" save end end def start if timer.start logger.info "[#] #{name} started at #{Time.now}" save end end def day_report(day = nil, type = nil) day = day.to_time rescue Time.now out = " - #{day.strftime('%d/%b/%y')}: #{humanize(timer.day_total(day))}" if type == 'hourly' out + "\n" + day_hourly_report(day) + "\n" elsif type == 'session' out + "\n" + day_session_report(day) + "\n" else out end end def report(from = nil, to = nil, type = nil) return "\nNo session data exists\n" if timer.intervals.length == 0 from ||= timer.intervals.first.first to ||= Time.now from, to = from.beginning_of_day, to.beginning_of_next_day day, days = from, [] while day < to days << day_report(day, type) if timer.day_total(day) > 0 day += 1.day end total = timer.total(from, to) days.join("\n") + "\n ----- TOTAL: #{humanize(total)} [#{humanize_words(total, 15)}]\n" end def save `mkdir -p #{Matewatch.data_dir}` File.open(Matewatch.data_dir + "/#{name}.yml", 'w+') {|f| f << to_yaml} end # destroy the yml file, first saving self to filename if specified def destroy(filename = nil) if filename File.open(Matewatch.data_dir + "/#{filename}.yml", 'w+') {|f| f << to_yaml} puts "project #{name} archived to #{Matewatch.data_dir}/#{filename}.yml" end `rm -f #{Matewatch.data_dir + "/#{name}.yml"}` end def to_yaml_properties ['@name', '@path', '@timer'] end protected # returns nil if there is no time - so you can decide not to print or write '0 minutes' or whatever def humanize(seconds) seconds = seconds.to_i hours, mins, secs = seconds / 1.hour, (seconds % 1.hour) / 1.minute, seconds%60 sprintf("%3d:%02d:%02d", hours, mins, secs) end def humanize_words(seconds, nearest = 1) mins = seconds.to_i / 1.minute mins += (nearest - mins%nearest) unless mins%nearest == 0 hours, mins = mins/60, mins%60 "#{hours} hour#{'s' if hours != 1}, #{mins} minute#{'s' if mins != 1} (#{nearest} min chunks)" end def day_hourly_report(day = nil) hours = [] (0..23).to_a.each do |hour| if (ht = timer.hour_total(hour, day)) > 0 hours << sprintf(" %2d00-%02d00 %s", hour, (hour+1)%24, humanize(ht)) end end hours.join("\n") end def day_session_report(day = nil) day = day.to_time rescue Time.now idx = 0 items = timer.intervals(day.beginning_of_day, day.beginning_of_next_day).collect do |start, stop| sprintf " %2d. %s-%s %s", idx+=1, start.strftime("%H:%M"), stop.strftime("%H:%M"), humanize(stop-start) end items.join("\n") end end class Watcher attr_reader :projects, :require_frontmost attr_writer :logger def initialize(options = {}) @textmate = OSA::app("Textmate") @require_frontmost = options[:require_frontmost] @logger = options[:logger] @projects = [] load_projects end def logger @logger ||= Logger.new end def project(name) @projects.find {|p| p.name == name} end def add_project(name, path, pos = nil) raise "#{name} is already beng watched" if project(name) returning Project.new(name, path) do |p| p.logger = logger pos ? projects.insert(pos, p) : projects.push(p) save_projects p.save end end def move_project(name, pos) raise "#{name} is not being watched" unless p = project(name) returning projects.delete(p) do |p| pos -= 1 pos = 0 if pos < 0 pos = projects.length if pos > projects.length projects.insert(pos, p) save_projects end end def remove_project(name) raise "#{name} is not being watched" unless p = project(name) returning projects.delete(p) do |p| save_projects p.destroy(p.name + '_' + Time.now.strftime("%Y%m%d_%H%M")) end end def pause load_projects @projects.each(&:stop) logger.info("=== matewatch PAUSED at #{Time.now}") @paused = true end def start @paused = false logger.info("=== matewatch STARTED at #{Time.now}") end def watch(poll = 1) poll = 1 if poll.to_i < 1 i, textmate_gone = 0, false logger.info "=== poll: #{poll} seconds, require frontmost: #{@require_frontmost ? 'true' : 'false'}\n" logger.info "=== press CTRL-c, or type kill #{Process.pid}, to shutdown\n" loop do unless @paused load_projects begin raise 'tm went away' if @require_frontmost && !@textmate.frontmost? active = find_active_project logger.info "+++ Textmate active at #{Time.now}" if textmate_gone textmate_gone = false rescue RuntimeError logger.info "--- Textmate inactive at #{Time.now}" unless textmate_gone textmate_gone = true end to_stop = @projects if !textmate_gone && active to_stop = @projects - [active] active.start end to_stop.each(&:stop) end sleep(poll) end end def shutdown @projects.each(&:stop) `rm -f #{Matewatch.data_dir}/pid` end protected def save_projects File.open("#{Matewatch.data_dir}/.projects.yml", 'w+') {|f| f << @projects.collect(&:name).to_yaml} end def load_projects project_names = YAML.load_file("#{Matewatch.data_dir}/.projects.yml") rescue nil project_names = [] unless project_names.is_a?(Array) unless project_names == @projects.collect(&:name) @projects = project_names.collect {|p| p = Project.load(p)} @projects.reject! {|p| !p.is_a?(Project) } @projects.each {|p| p.logger = logger} logger.info "=== matewatching: #{projects.collect(&:name).to_sentence}" save_projects end end # return the active document def find_active_project if @textmate.documents.first active_path = @textmate.documents.first.path @projects.find {|p| active_path =~ /^#{p.path}/} end end end end class Time def beginning_of_next_day beginning_of_day + 24.hours end end Matewatch.new(ARGV)