Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

First commit, migrating from SVN fresh into git.

  • Loading branch information...
commit e845d9e6761f558029e80a827ca32f5155793fc9 0 parents
Eric Mill authored
22 LICENSE
@@ -0,0 +1,22 @@
+Copyright (c) 2007 Eric Mill
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
104 README
@@ -0,0 +1,104 @@
+===============================================================================
+ Tracker - Command line time tracker for Basecamp
+===============================================================================
+
+Tracker is a command line interface to log and manage your times on your
+Basecamp. It uses and extends the Basecamp API Ruby wrapper.
+
+
+=== Usage ===
+
+Configure the tracker with your Basecamp URL, login details, and whether the
+Basecamp uses SSL. This will take a while as it caches project information
+from your Basecamp. Example:
+
+ track configure example.clientsection.com jsmith jsmithX11 true
+
+Set a project as "current", so any times logged are assumed to be meant for
+that project.
+
+ track project "Johnson Industries"
+ track project joh
+
+Log a time by giving the duration, or by giving two times. You can optionally
+specify the project to log time to, which won't change the default project.
+
+ track log 0.25 "Log message"
+ track log :15 "Log message" "Johnson Industries"
+ track log 2:30p 5:30pm "Log message"
+ track log 10:00 1:00 "Log message" joh
+
+ In the last case (10:00 to 1:00), the tracker will assume that the 2nd time
+ is later than the 1st one, and calculate it as 3 hours, not -9.
+
+Log time by starting a timer. If you don't specify a starting time, it's
+assumed you're starting now. You can optionally specify a project, which
+will change the default project.
+
+ track start
+ track start "Johnson Industries"
+ track start 3:15
+ track start 3:15 "Johnson Industries"
+
+Stop the timer to log elapsed time. If you don't specify an ending time, it's
+assumed you're stopping now.
+
+ track stop "Log message"
+ track stop 5:15pm "Log message"
+
+Pause and unpause the timer.
+
+ track pause
+
+Cancel all time tracking and reset counters to 0, if the timer is running or
+paused.
+
+ track cancel
+
+List times logged that day, including totals:
+
+ track times
+
+Delete a logged time from Basecamp with "undo". If you don't specify an
+ID, it's assumed you want to delete the last logged time.
+
+ track undo
+ track undo 6861536
+
+Set a variable, such as the minute increment to round times to, or any Basecamp
+authorization credentials.
+
+ track set rounding 15
+
+
+See the list of available projects to track time against.
+
+ track projects
+
+See whether the tracker is configured correctly, the current project, and
+if/when the timer was started or paused:
+
+ track status
+
+See a general or command-specific help message:
+
+ track help
+ track help log
+
+
+== General ==
+
+ * Project names can be entered as starting fragments. For example, if
+ "Johnson Industries" was the only project beginning with "joh", you could
+ reference it as "joh" (e.g. "track project joh"). If more than one
+ project starts with a fragment, the first one that matches is chosen.
+
+ * Times:
+ - Valid formats for times of day are: 10:00, 9:00pm, 23:30, 8, 1am, 10p
+ - Invalid formats for times of day are: 1230, 1120pm, 22:00am
+
+ * When calculating elapsed time, minutes are rounded up to be integers, and
+ then rounded to the nearest 15 minute increment, by default. So a 1-minute
+ time will be logged as 15, 15 as 15, 16 as 30, etc. Use the 'set' command
+ to change the increment that times are rounded to. Times logged using the
+ 'log' command will not be rounded.
30 bin/commit
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+MSG=$1
+LOG=-1
+START=-1
+
+if [ "$1" == "-l" ]
+then
+ MSG=$2
+ LOG=0
+elif [ "$1" == "-ls" ]
+then
+ MSG=$2
+ LOG=0
+ START=0
+fi
+
+
+svn ci -m "$MSG"
+
+
+if [ $LOG -eq 0 ]
+then
+ track stop "$MSG"
+fi
+
+if [ $START -eq 0 ]
+then
+ track start
+fi
278 bin/track
@@ -0,0 +1,278 @@
+#!/usr/bin/ruby
+
+require 'rubygems'
+require 'time_tracker'
+
+
+USAGE = {
+ "configure" => ["track configure [url username password ssl? [project-name]]"],
+ "set" => ["track set [key value]"],
+ "project" => ["track project project-name"],
+ "projects" => ["track projects"],
+ "log" => ["track log start-time end-time message [project-name]", "track log duration message [project-name]"],
+ "undo" => ["track undo [todo-id]"],
+ "status" => ["track status"],
+ "times" => ["track times"],
+ "help" => ["track help [subcommand]"],
+ "start" => ["track start [project-name]", "track start start-time [project-name]"],
+ "stop" => ["track stop [end-time] message"],
+ "cancel" => ["track cancel"],
+ "pause" => ["track pause"],
+}
+COMMANDS = USAGE.keys
+
+
+# commands
+
+def configure
+ configuration! if ARGV.empty?
+
+ url = ARGV.shift
+ user_name = ARGV.shift
+ password = ARGV.shift
+ use_ssl = (ARGV.shift == "true")
+
+ tracker.configure!(url, user_name, password, use_ssl)
+ configuration!
+end
+
+def project
+ usage! if ARGV.empty?
+ tracker.set!("current_project", project_name(ARGV.shift))
+ puts "Set current project to #{tracker.current_project}."
+end
+
+def projects
+ puts "Projects:"
+ tracker.projects.values.map {|v| v.capitalize_all}.sort.each {|name| puts " #{name}"}
+end
+
+def set
+ variables! if ARGV.empty?
+ usage! if ARGV[1].blank?
+
+ exceptions = {"ssl" => "use_ssl", "username" => "user_name"}
+ key, value = [ARGV.shift, ARGV.shift]
+
+ tracker.set!(exceptions[key] || key, value)
+ puts "Set #{key} to #{value}."
+end
+
+def log
+ usage! if ARGV[0].blank? or ARGV[1].blank?
+
+ if ARGV[0].to_time and ARGV[1].to_time
+ start_time = ARGV.shift.to_time
+ end_time = ARGV.shift.to_time
+ # for example, if the times given are "10:00" and "1:30", assume the meridian changed
+ if start_time > end_time
+ if start_time - end_time < (12*60*60)
+ end_time += (12*60*60)
+ elsif start_time - end_time < (24*60*60)
+ end_time += (24*60*60)
+ end
+ end
+
+ duration = ":#{end_time.minutes_since(start_time).round_to(tracker.config.rounding.to_i)}"
+ else
+ duration = ARGV.shift
+ end
+
+ message = ARGV.shift
+ project = project_name(ARGV.shift)
+
+ if time = tracker.log_time(duration, message, project)
+ puts "Logged time:"
+ puts display(time)
+ else
+ error! "Couldn't log time; make sure the project name is spelled right."
+ end
+end
+
+def start
+ if tracker.started?
+ puts "Already tracking time."
+ status
+ exit
+ elsif tracker.paused?
+ return pause
+ end
+
+ start_time = nil
+ project = nil
+ if ARGV.any?
+ start_time = ARGV.shift if ARGV[0].to_time
+ project = project_name(ARGV.shift) if ARGV.any?
+ tracker.set!("current_project", project) if project
+ end
+
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
+
+ tracker.start!(start_time)
+ puts "Started tracking time for #{tracker.current_project} at #{tracker.start_time.strftime("%I:%M%p").downcase}."
+end
+
+def cancel
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started? or tracker.paused?
+ tracker.cancel!
+ puts "Canceled time tracking for #{tracker.current_project}."
+end
+
+def pause
+ if tracker.paused?
+ tracker.start!
+ puts "Resumed tracking time at #{tracker.start_time.strftime("%I:%M%p").downcase} with #{tracker.minutes_elapsed} minutes elapsed."
+ elsif tracker.started?
+ tracker.pause!
+ puts "Paused tracking time with #{tracker.minutes_elapsed} minutes elapsed."
+ else
+ error! "Tracker not started."
+ end
+end
+
+def stop
+ usage! if ARGV.empty?
+ error! "Tracker not started. Use the 'log' command to log a complete time." unless tracker.started?
+
+ end_time = ARGV.shift if ARGV[0].to_time
+ message = ARGV.shift
+ usage! if message.blank?
+
+ error! "No project specified. Specify a project name as the last argument, or set a default project in config.yml." unless tracker.current_project
+
+ time = tracker.stop!(message, end_time)
+
+ if time
+ puts "Logged time:"
+ puts display(time)
+ else
+ error! "Couldn't log time; make sure the project name is spelled right."
+ end
+end
+
+def times
+ if tracker.times.empty?
+ puts "No times recorded today."
+ return
+ end
+
+ puts "Today's times:"
+
+ projects = tracker.times.map {|time| time.project_id}.uniq
+ sums = {}
+ projects.each do |id|
+ sums[id] = tracker.times.select {|time| time.project_id == id}.map {|time| time.hours.to_f}.sum
+ end
+ total_sum = sums.values.sum
+
+ tracker.times.reverse_each {|time| puts display(time)}
+ puts
+ puts "Totals:"
+ projects.each {|id| puts " #{tracker.project_name(id)}: #{sums[id]} hours"}
+ puts
+ puts " All projects: #{total_sum} hours"
+end
+
+def undo
+ error! "No times recorded." unless tracker.times.any?
+
+ time = tracker.delete_time(ARGV.shift)
+ puts "Deleted time:"
+ puts display(time)
+end
+
+def status
+ if tracker.configured?
+ puts "Tracker configured correctly, Basecamp communication online."
+ else
+ puts "Tracker not configured correctly, Basecamp communication offline."
+ end
+
+ puts "Current project: #{tracker.current_project || "[No project set.]"}"
+
+ if tracker.started?
+ puts "\nTracking started:\n #{tracker.start_time.strftime("%I:%M%p")} (#{tracker.minutes_elapsed} minutes elapsed)"
+ elsif tracker.paused?
+ puts "\nTracking paused:\n #{tracker.minutes_elapsed} minutes elapsed."
+ else
+ puts "\nNot currently tracking time."
+ end
+end
+
+def help
+ usage!(ARGV.shift) if ARGV[0].in?(COMMANDS)
+
+ puts "usage: track <subcommand> [args]"
+ puts "help: track help <subcommand>"
+ puts "\nProject names can be a beginning fragment."
+ puts "\nAvailable subcommands:"
+ COMMANDS.sort.each {|command| puts " #{command}"}
+end
+
+
+# helper
+
+def usage!(command = nil)
+ puts "Usage:"
+ USAGE[command || @command].each {|msg| puts " #{msg}"}
+ puts
+ exit
+end
+
+def error!(msg = nil)
+ puts msg if msg
+ puts
+ exit
+end
+
+def display(time)
+ " #{time.created_at.strftime("%I:%M%p").downcase} ##{time.id} [#{tracker.project_name(time.project_id)}] #{time.hours} - #{time.description}"
+end
+
+def tracker
+ @tracker ||= TimeTracker.new
+end
+
+def project_name(fragment)
+ return if fragment.blank? or tracker.projects.nil?
+ tracker.projects.values.find {|name| name == fragment or name =~ /^#{fragment}/i}
+end
+
+def variables!
+ puts "Variables:"
+ puts " url\t\tBasecamp URL. (e.g. thoughtbot.clientsection.com)"
+ puts " username\tBasecamp username."
+ puts " password\tBasecamp password."
+ puts " ssl\t\tWhether your Basecamp uses SSL (https)."
+ puts " rounding\tRound time to the nearest ___ minutes. Set to 0 to disable."
+ puts
+ exit
+end
+
+def configuration!
+ puts "Configuration:"
+ puts " url\t\t#{tracker.config.url}"
+ puts " username\t#{tracker.config.user_name}"
+ puts " password\t#{tracker.config.password}"
+ puts " ssl\t\t#{tracker.config.use_ssl}"
+ puts " rounding\t#{tracker.config.rounding}"
+ puts
+ exit
+end
+
+def unconfigured!
+ puts
+ puts "Tracker not configured correctly, cannot communicate with Basecamp."
+ puts
+ exit
+end
+
+
+@command = ARGV[0].in?(COMMANDS) ? ARGV.shift : 'help'
+
+requires_connection = COMMANDS.reject {|c| c.in? ["help", "set", "configure"]}
+unconfigured! if @command.in?(requires_connection) and !tracker.configured?
+
+puts
+self.send(@command)
+puts
499 lib/basecamp.rb
@@ -0,0 +1,499 @@
+# The "official" (but public domain) Basecamp Ruby wrapper can be downloaded from:
+# http://developer.37signals.com/basecamp/basecamp.rb
+
+# Modified by Eric Mill
+# - Changed 'xml-simple' to 'xmlsimple', the lib name has changed to match the gem name
+# - Added some debug output
+
+DEBUG = false
+
+# the following are all standard ruby libraries
+
+require 'net/https'
+require 'yaml'
+require 'date'
+require 'time'
+
+require 'rubygems'
+require 'xmlsimple'
+
+# An interface to the Basecamp web-services API. Usage is straightforward:
+#
+# session = Basecamp.new('your.basecamp.com', 'username', 'password')
+# puts "projects: #{session.projects.length}"
+class Basecamp
+
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
+ class Record #:nodoc:
+ attr_reader :type
+
+ def initialize(type, hash)
+ @type = type
+ @hash = hash
+ end
+
+ def [](name)
+ name = dashify(name)
+ case @hash[name]
+ when Hash then
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
+ Record.new(name, @hash[name])
+ else @hash[name]
+ end
+ end
+
+ def id
+ @hash["id"]
+ end
+
+ def attributes
+ @hash.keys
+ end
+
+ def respond_to?(sym)
+ super || @hash.has_key?(dashify(sym))
+ end
+
+ def method_missing(sym, *args)
+ if args.empty? && !block_given? && respond_to?(sym)
+ self[sym]
+ else
+ super
+ end
+ end
+
+ def to_s
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
+ end
+
+ def inspect
+ to_s
+ end
+
+ private
+
+ def dashify(name)
+ name.to_s.tr("_", "-")
+ end
+ end
+
+ # A wrapper to represent a file that should be uploaded. This is used so that
+ # the form/multi-part encoder knows when to encode a field as a file, versus
+ # when to encode it as a simple field.
+ class FileUpload
+ attr_reader :filename, :content
+
+ def initialize(filename, content)
+ @filename = filename
+ @content = content
+ end
+ end
+
+ attr_accessor :use_xml
+
+ # Connects
+ def initialize(url, user_name, password, use_ssl = false)
+ @use_xml = false
+ @user_name, @password = user_name, password
+ connect!(url, use_ssl)
+ end
+
+ # Return the list of all accessible projects.
+ def projects
+ records "project", "/project/list"
+ end
+
+ # Returns the list of message categories for the given project
+ def message_categories(project_id)
+ records "post-category", "/projects/#{project_id}/post_categories"
+ end
+
+ # Returns the list of file categories for the given project
+ def file_categories(project_id)
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
+ end
+
+ # Return information for the company with the given id
+ def company(id)
+ record "/contacts/company/#{id}"
+ end
+
+ # Return an array of the people in the given company. If the project-id is
+ # given, only people who have access to the given project will be returned.
+ def people(company_id, project_id=nil)
+ url = project_id ? "/projects/#{project_id}" : ""
+ url << "/contacts/people/#{company_id}"
+ records "person", url
+ end
+
+ # Return information about the person with the given id
+ def person(id)
+ record "/contacts/person/#{id}"
+ end
+
+ # Return information about the message(s) with the given id(s). The API
+ # limits you to requesting 25 messages at a time, so if you need to get more
+ # than that, you'll need to do it in multiple requests.
+ def message(*ids)
+ result = records("post", "/msg/get/#{ids.join(",")}")
+ result.length == 1 ? result.first : result
+ end
+
+ # Returns a summary of all messages in the given project (and category, if
+ # specified). The summary is simply the title and category of the message,
+ # as well as the number of attachments (if any).
+ def message_list(project_id, category_id=nil)
+ url = "/projects/#{project_id}/msg"
+ url << "/cat/#{category_id}" if category_id
+ url << "/archive"
+
+ records "post", url
+ end
+
+ # Create a new message in the given project. The +message+ parameter should
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
+ # should be notified of the post.
+ #
+ # If you want to add attachments to the message, the +attachments+ parameter
+ # should be an array of hashes, where each has has a :name key (optional),
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
+ # instance.
+ #
+ # msg = session.post_message(158141,
+ # { :title => "Requirements",
+ # :body => "Here are the requirements documents you asked for.",
+ # :category_id => 2301121 },
+ # [john.id, martha.id],
+ # [ { :name => "Primary Requirements",
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
+ def post_message(project_id, message, notify=[], attachments=[])
+ prepare_attachments(attachments)
+ record "/projects/#{project_id}/msg/create",
+ :post => message,
+ :notify => notify,
+ :attachments => attachments
+ end
+
+ # Edit the message with the given id. The +message+ parameter should
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
+ # should be notified of the post.
+ #
+ # The +attachments+ parameter, if used, should be the same as described for
+ # #post_message.
+ def update_message(id, message, notify=[], attachments=[])
+ prepare_attachments(attachments)
+ record "/msg/update/#{id}",
+ :post => message,
+ :notify => notify,
+ :attachments => attachments
+ end
+
+ # Deletes the message with the given id, and returns it.
+ def delete_message(id)
+ record "/msg/delete/#{id}"
+ end
+
+ # Return a list of the comments for the specified message.
+ def comments(post_id)
+ records "comment", "/msg/comments/#{post_id}"
+ end
+
+ # Retrieve a specific comment
+ def comment(id)
+ record "/msg/comment/#{id}"
+ end
+
+ # Add a new comment to a message. +comment+ must be a hash describing the
+ # comment. You can add attachments to the comment, too, by giving them in
+ # an array. See the #post_message method for a description of how to do that.
+ def create_comment(post_id, comment, attachments=[])
+ prepare_attachments(attachments)
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
+ :attachments => attachments
+ end
+
+ # Update the given comment. Attachments follow the same format as #post_message.
+ def update_comment(id, comment, attachments=[])
+ prepare_attachments(attachments)
+ record "/msg/update_comment", :comment_id => id,
+ :comment => comment, :attachments => attachments
+ end
+
+ # Deletes (and returns) the given comment.
+ def delete_comment(id)
+ record "/msg/delete_comment/#{id}"
+ end
+
+ # =========================================================================
+ # TODO LISTS AND ITEMS
+ # =========================================================================
+
+ # Marks the given item completed.
+ def complete_item(id)
+ record "/todos/complete_item/#{id}"
+ end
+
+ # Marks the given item uncompleted.
+ def uncomplete_item(id)
+ record "/todos/uncomplete_item/#{id}"
+ end
+
+ # Creates a new to-do item.
+ def create_item(list_id, content, responsible_party=nil, notify=true)
+ record "/todos/create_item/#{list_id}",
+ :content => content, :responsible_party => responsible_party,
+ :notify => notify
+ end
+
+ # Creates a new list using the given hash of list metadata.
+ def create_list(project_id, list)
+ record "/projects/#{project_id}/todos/create_list", list
+ end
+
+ # Deletes the given item from it's parent list.
+ def delete_item(id)
+ record "/todos/delete_item/#{id}"
+ end
+
+ # Deletes the given list and all of its items.
+ def delete_list(id)
+ record "/todos/delete_list/#{id}"
+ end
+
+ # Retrieves the specified list, and all of its items.
+ def get_list(id)
+ record "/todos/list/#{id}"
+ end
+
+ # Return all lists for a project. If complete is true, only completed lists
+ # are returned. If complete is false, only uncompleted lists are returned.
+ def lists(project_id, complete=nil)
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
+ end
+
+ # Repositions an item to be at the given position in its list
+ def move_item(id, to)
+ record "/todos/move_item/#{id}", :to => to
+ end
+
+ # Repositions a list to be at the given position in its project
+ def move_list(id, to)
+ record "/todos/move_list/#{id}", :to => to
+ end
+
+ # Updates the given item
+ def update_item(id, content, responsible_party=nil, notify=true)
+ record "/todos/update_item/#{id}",
+ :item => { :content => content }, :responsible_party => responsible_party,
+ :notify => notify
+ end
+
+ # Updates the given list's metadata
+ def update_list(id, list)
+ record "/todos/update_list/#{id}", :list => list
+ end
+
+ # =========================================================================
+ # MILESTONES
+ # =========================================================================
+
+ # Complete the milestone with the given id
+ def complete_milestone(id)
+ record "/milestones/complete/#{id}"
+ end
+
+ # Create a new milestone for the given project. +data+ must be hash of the
+ # values to set, including +title+, +deadline+, +responsible_party+, and
+ # +notify+.
+ def create_milestone(project_id, data)
+ create_milestones(project_id, [data]).first
+ end
+
+ # As #create_milestone, but can create multiple milestones in a single
+ # request. The +milestones+ parameter must be an array of milestone values as
+ # descrbed in #create_milestone.
+ def create_milestones(project_id, milestones)
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
+ end
+
+ # Destroys the milestone with the given id.
+ def delete_milestone(id)
+ record "/milestones/delete/#{id}"
+ end
+
+ # Returns a list of all milestones for the given project, optionally filtered
+ # by whether they are completed, late, or upcoming.
+ def milestones(project_id, find="all")
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
+ end
+
+ # Uncomplete the milestone with the given id
+ def uncomplete_milestone(id)
+ record "/milestones/uncomplete/#{id}"
+ end
+
+ # Updates an existing milestone.
+ def update_milestone(id, data, move=false, move_off_weekends=false)
+ record "/milestones/update/#{id}", :milestone => data,
+ :move_upcoming_milestones => move,
+ :move_upcoming_milestones_off_weekends => move_off_weekends
+ end
+
+ # Make a raw web-service request to Basecamp. This will return a Hash of
+ # Arrays of the response, and may seem a little odd to the uninitiated.
+ def request(path, parameters = {}, second_try = false)
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
+
+ if DEBUG
+ puts "Reponse:"
+ puts response.body
+ end
+
+ if response.code.to_i / 100 == 2
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
+ 'contentkey' => '__content__', 'forcecontent' => true)
+ typecast_value(result)
+ elsif response.code == "302" && !second_try
+ connect!(@url, !@use_ssl)
+ request(path, parameters, true)
+ else
+ raise "#{response.message} (#{response.code})"
+ end
+ end
+
+ # A convenience method for wrapping the result of a query in a Record
+ # object. This assumes that the result is a singleton, not a collection.
+ def record(path, parameters={})
+ result = request(path, parameters)
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
+ end
+
+ # A convenience method for wrapping the result of a query in Record
+ # objects. This assumes that the result is a collection--any singleton
+ # result will be wrapped in an array.
+ def records(node, path, parameters={})
+ result = request(path, parameters).values.first or return []
+ result = result[node] or return []
+ result = [result] unless Array === result
+ result.map { |row| Record.new(node, row) }
+ end
+
+ private
+
+ def connect!(url, use_ssl)
+ @use_ssl = use_ssl
+ @url = url
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
+ @connection.use_ssl = @use_ssl
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
+ end
+
+ def convert_body(body)
+ body = use_xml ? body.to_xml : body.to_yaml
+ end
+
+ def content_type
+ use_xml ? "application/xml" : "application/x-yaml"
+ end
+
+ def post(path, body, header={})
+ if DEBUG
+ puts "POSTing:"
+ p YAML.load(body)
+ end
+
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
+ request.basic_auth(@user_name, @password)
+ @connection.request(request, body)
+ end
+
+ def store_file(contents)
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
+ 'Accept' => 'application/xml')
+
+ if response.code == "200"
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
+ return result["upload"]["id"]
+ else
+ raise "Could not store file: #{response.message} (#{response.code})"
+ end
+ end
+
+ def typecast_value(value)
+ case value
+ when Hash
+ if value.has_key?("__content__")
+ content = translate_entities(value["__content__"]).strip
+ case value["type"]
+ when "integer" then content.to_i
+ when "boolean" then content == "true"
+ when "datetime" then Time.parse(content)
+ when "date" then Date.parse(content)
+ else content
+ end
+ # a special case to work-around a bug in XmlSimple. When you have an empty
+ # tag that has an attribute, XmlSimple will not add the __content__ key
+ # to the returned hash. Thus, we check for the presense of the 'type'
+ # attribute to look for empty, typed tags, and simply return nil for
+ # their value.
+ elsif value.keys == %w(type)
+ nil
+ elsif value["nil"] == "true"
+ nil
+ # another special case, introduced by the latest rails, where an array
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
+ # one key is 'type' and the other is the actual array value.
+ elsif value.keys.length == 2 && value["type"] == "array"
+ value.delete("type")
+ typecast_value(value)
+ else
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
+ h[k] = typecast_value(v)
+ h
+ end
+ end
+ when Array
+ value.map! { |i| typecast_value(i) }
+ case value.length
+ when 0 then nil
+ when 1 then value.first
+ else value
+ end
+ else
+ raise "can't typecast #{value.inspect}"
+ end
+ end
+
+ def translate_entities(value)
+ value.gsub(/&lt;/, "<").
+ gsub(/&gt;/, ">").
+ gsub(/&quot;/, '"').
+ gsub(/&apos;/, "'").
+ gsub(/&amp;/, "&")
+ end
+
+ def prepare_attachments(list)
+ (list || []).each do |data|
+ upload = data[:file]
+ id = store_file(upload.content)
+ data[:file] = { :file => id,
+ :content_type => "application/octet-stream",
+ :original_filename => upload.filename }
+ end
+ end
+end
+
+# A minor hack to let Xml-Simple serialize symbolic keys in hashes
+class Symbol
+ def [](*args)
+ to_s[*args]
+ end
+end
+
+class Hash
+ def to_xml
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
+ end
+end
369 lib/time_tracker.rb
@@ -0,0 +1,369 @@
+require 'fileutils'
+require 'basecamp'
+
+class TimeTracker
+
+ attr_reader :basecamp, :projects, :times, :person_id, :config
+
+ def initialize
+ initialize_data!
+
+ # need to make booleans a special case and transform there here forevermore
+ ["use_ssl"].each do |key|
+ @config[key] = true if @config[key] == "true"
+ @config[key] = false if @config[key] == "false"
+ end
+ # defaults
+ {"rounding" => 15, "minutes_logged" => 0}.each_pair {|key, value| @config[key] ||= value}
+
+ @basecamp = Basecamp.new @config.url, @config.user_name, @config.password, @config.use_ssl
+
+ # set up a couple of attr_readers
+ @person_id = @config.person_id
+ @projects = @config.projects
+ end
+
+
+ # main API
+
+ def configure!(url, user_name, password, use_ssl)
+ write_config("url" => url, "user_name" => user_name, "password" => password, "use_ssl" => use_ssl)
+ test_basecamp!
+ end
+
+ def set!(key, value)
+ write_config(key.to_s => value)
+ test_basecamp! if key.in? ["url", "user_name", "password", "use_ssl"]
+ end
+
+ def start!(start_time = nil)
+ return if started?
+
+ write_config("start_time" => (start_time.to_time || Time.now))
+ end
+
+ def stop!(message, end_time = nil)
+ return unless started?
+
+ minutes = minutes_elapsed(end_time).round_to(@config.rounding.to_i)
+
+ write_config("start_time" => nil, "minutes_logged" => 0)
+ log_time(":#{minutes}", message)
+ end
+
+ def cancel!
+ return unless started? or paused?
+
+ write_config("start_time" => nil, "minutes_logged" => 0)
+ end
+
+ def pause!
+ return unless started?
+
+ write_config("start_time" => nil, "minutes_logged" => minutes_elapsed)
+ end
+
+ def log_time(duration, message, project = nil)
+ project_id = project_id(project || current_project)
+ return unless project_id
+
+ save @basecamp.log_time(project_id, @person_id, Time.now, duration, message)
+ end
+
+ def delete_time(time_id = nil)
+ time = time_id ? find_time(time_id) : @times.first
+ return unless time
+
+ @basecamp.delete_time(time.id, time.project_id)
+ @times.delete time
+ write_times
+
+ time
+ end
+
+ # secondary API
+
+ def current_project
+ @config.current_project.capitalize_all
+ end
+
+ def configured?
+ @config.configured
+ end
+
+ def test_basecamp!
+ @basecamp = Basecamp.new(@config.url, @config.user_name, @config.password, @config.use_ssl)
+ write_config("configured" => @basecamp.test_auth)
+ sync_basecamp if configured?
+ end
+
+ def sync_basecamp
+ puts "Initializing, may take a few seconds..."
+ $stdout.flush if $stdout and $stdout.respond_to? :flush
+ get_person(@config.user_name)
+ get_projects
+ end
+
+ def started?
+ !@config.start_time.nil?
+ end
+
+ def paused?
+ !started? and (@config.minutes_logged > 0)
+ end
+
+ def minutes_elapsed(end_time = nil)
+ if started?
+ @config.minutes_logged + (end_time || Time.now).minutes_since(start_time)
+ else
+ @config.minutes_logged
+ end
+ end
+
+ def start_time
+ @config.start_time
+ end
+
+ def project_name(project_id)
+ @projects[project_id].capitalize_all
+ end
+
+ def project_id(name)
+ @projects.invert[name.to_s.downcase]
+ end
+
+ def save(record)
+ return unless record
+ record = record.to_hash
+ record.created_at = Time.now
+ @times.unshift record
+ prune_times
+ write_times
+ @times.first
+ end
+
+ def inspect
+ config = @config.dup
+ config.projects = config.projects.values.map {|name| name.capitalize_all}.join(", ")
+ config.to_yaml.gsub(/^---/, "")
+ end
+
+ private
+
+ def initialize_data!
+ FileUtils.mkdir data_path if !File.exists? data_path
+ if File.exists?(config_file)
+ @config = YAML.load_file(config_file)
+ else
+ @config = {}
+ write_config
+ end
+
+ if File.exists?(times_file)
+ @times = YAML.load_file(times_file)
+ else
+ @times = []
+ write_times
+ end
+ true
+ end
+
+ def config_file
+ File.join data_path, "config.yml"
+ end
+
+ def times_file
+ File.join data_path, "times.yml"
+ end
+
+ def data_path
+ File.join ENV['HOME'], ".time_tracker"
+ end
+
+ def get_projects
+ @projects = {}
+ @basecamp.projects.each {|project| @projects[project.id] = project.name.downcase}
+ write_config("projects" => @projects)
+ end
+
+ def get_person(user_name)
+ if record = basecamp.person(user_name)
+ @person_id = record.id
+ write_config("person_id" => @person_id)
+ end
+ end
+
+ def find_time(id)
+ @times.find {|time| time.id.to_s == id}
+ end
+
+ # keep only today's times
+ def prune_times
+ now = Time.now.strftime("%Y-%m-%d")
+ @times.reject! {|time| time["date"].strftime("%Y-%m-%d") != now}
+ end
+
+ def write_config(params = {})
+ params.each_pair {|key, value| @config[key] = value}
+ File.open(config_file, "w") {|file| file.write(@config.to_yaml)}
+ end
+
+ def write_times
+ File.open(times_file, "w") {|file| file.write(@times.to_yaml)}
+ end
+
+
+end
+
+
+# Basecamp wrapper extensions
+
+class Basecamp
+
+ def log_time(project_id, person_id, date, hours, description = nil, todo_item_id = nil)
+ entry = {"project_id" => project_id, "person_id" => person_id, "date" => date.to_s, "hours" => hours.to_s}
+ entry["description"] = description if description
+ entry["todo_item_id"] = todo_item_id if todo_item_id
+ record "/time/save_entry", :entry => entry
+ end
+
+ def delete_time(id, project_id)
+ record "/projects/#{project_id}/time/delete_entry/#{id}"
+ end
+
+ # overrides existing #person to accept an ID or a user_name - IDs of 0 will be interpreted as a user_name
+ def person(identifier)
+ if identifier.is_a? Fixnum or identifier.to_i > 0
+ record "/contacts/person/#{identifier}"
+ else # identifier is a username
+ all_people.find {|person| person["user-name"] == identifier}
+ end
+ end
+
+ def companies
+ records "company", "/contacts/companies"
+ end
+
+ # Fetches all people from all companies and prunes for uniqueness
+ def all_people
+ companies.map do |company|
+ records "person", "/contacts/people/#{company.id}"
+ end.flatten
+ end
+
+ # tests credentials
+ def test_auth
+ begin
+ projects
+ true
+ rescue
+ false
+ end
+ end
+
+ class Record
+ def to_hash
+ hash = {}
+ self.attributes.each do |attr|
+ hash[attr.undashify] = self[attr]
+ end
+ hash
+ end
+ end
+
+end
+
+
+# Core extensions
+
+class String
+ def to_time
+ matches = self.match(/^(\d\d?)(:\d\d)?(am?|pm?)?$/i)
+ return unless matches
+
+ hour = matches[1].to_i
+ minutes = matches[2] ? matches[2].gsub(/:/, "").to_i : 0
+ meridian = matches[3]
+
+ return unless hour > 0
+ hour += 12 if meridian =~ /^p/ and hour < 12
+ return unless hour <= 24
+
+ now = Time.now
+ Time.mktime(now.year, now.month, now.day, hour, minutes)
+ end
+
+ def capitalize_all(separator = " ")
+ split(" ").map {|s| s.capitalize}.join(" ")
+ end
+
+ def blank?
+ empty?
+ end
+
+ def dashify
+ self.tr '_', '-'
+ end
+
+ def undashify
+ self.tr '-', '_'
+ end
+end
+
+class Time
+ def to_time
+ self
+ end
+
+ def minutes_since(time)
+ ((self - time) / 60).ceil
+ end
+end
+
+class NilClass
+ def blank?
+ true
+ end
+
+ def method_missing(method, *args)
+ self
+ end
+end
+
+class Hash
+ def id
+ method_missing :id
+ end
+
+ def method_missing(method, *args)
+ if method.to_s =~ /=$/
+ self[method.to_s.tr('=','')] = args.first
+ else
+ self[method.to_s]
+ end
+ end
+end
+
+class Object
+ def in?(collection)
+ collection.include? self
+ end
+end
+
+class Fixnum
+ def round_to(minutes)
+ return self unless minutes > 0
+
+ if self > 0 and self % minutes == 0
+ self
+ else
+ self + (minutes - (self % minutes))
+ end
+ end
+end
+
+class Array
+ def sum
+ inject {|sum, x| sum + x}
+ end
+end
27 time-tracker.gemspec
@@ -0,0 +1,27 @@
+require 'rubygems'
+
+spec = Gem::Specification.new do |s|
+ s.name = 'time-tracker'
+ s.version = "1.0.0"
+ s.platform = Gem::Platform::RUBY
+ s.summary = "A command line time tracker interface for time logging in Basecamp."
+
+ s.author = "Eric Mill"
+ s.email = "kprojection@gmail.com"
+ # s.homepage =
+
+
+ s.files = Dir.glob("{bin,lib,data}/**/*") + %w(README LICENSE)
+ s.require_path = 'lib'
+ s.autorequire = 'time_tracker'
+
+ s.bindir = "bin"
+ s.executables << "track"
+
+ s.add_dependency 'xml-simple'
+end
+
+if $0==__FILE__
+ require 'rubygems/builder'
+ Gem::Builder.new(spec).build
+end
Please sign in to comment.
Something went wrong with that request. Please try again.