Skip to content

Instantly share code, notes, and snippets.

@lak
Created August 28, 2016 20:01
Show Gist options
  • Save lak/dd11a346921ae11d6da9bdee656adcbe to your computer and use it in GitHub Desktop.
Save lak/dd11a346921ae11d6da9bdee656adcbe to your computer and use it in GitHub Desktop.
Simplistic calendar analysis
#!/usr/bin/ruby
require 'google/apis/calendar_v3'
require 'googleauth'
require 'googleauth/stores/file_token_store'
require 'fileutils'
SETTINGS = OpenStruct.new(
:oob_uri => 'urn:ietf:wg:oauth:2.0:oob',
:application_name => 'LAK Cal Stat',
:client_secrets_path => 'client_id.json',
:credentials_path => File.join(Dir.home, 'etc', "calstat.yaml"),
:scope => Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY,
:data_dir => File.join(Dir.home, 'var', 'calstat')
)
week = 604800
PERIOD = OpenStruct.new(
:week => week,
:month => week * 4,
:quarter => week * 12
)
COLORS = []
COLORS[0] = :default
COLORS[1] = :transit
COLORS[2] = :external
COLORS[3] = :company
COLORS[4] = :team
COLORS[7] = :open
COLORS[8] = :notes
COLORS[9] = :maybe_interview
COLORS[11] = :notice
class Reports
OUTPUT_TEMPLATE = "%-25s: %s"
def self.print(label, data)
string = (OUTPUT_TEMPLATE % [label, data])
puts string
end
# Currently just returning a length of zero for all day events. This is probably sufficient,
# but isn't technically correct. It saves on everyone who calls this method needing
# to do error handling for this (common) case, and seems to fit my use cases here.
def self.event_length(event)
start_time = event.start.date_time or return(0)
end_time = event.end.date_time or return(0)
length = (end_time.to_time.to_f - start_time.to_time.to_f) / 60 / 60
return length
end
class OpenTime
def initialize
@time = 0.0
@count = 0
end
def process_event(event)
return unless event.color_id == "7"
@count += 1
@time += Reports.event_length(event)
end
def print_result
Reports.print("Open Time", "%0.2f hours; %i event(s)" % [@time, @count])
end
end
class BusyEvenings
def initialize
@days = {}
@count = 0
end
def process_event(event)
unless time = event.end.date_time
# puts "Skipping %s because it has no end time" % [event.summary]
return
end
if time.hour > 18
# puts "After-work event at %s: %s (%s)" % [event.end.date_time, event.summary, time.hour]
@days[time.day] = true
@count += 1
end
end
def print_result
days_busy = @days.length
Reports.print("Days busy after work", "%s; Total events: %s" % [days_busy, @count])
end
end
class WeekendWork
def initialize
@days = {}
@count = 0
@hours = 0.0
end
def process_event(event)
if event.start.date_time
date = event.start.date_time
else
date = Date.parse(event.start.date)
end
return unless (date.sunday? or date.saturday?)
@days[date.day] = true
@count += 1
@hours += Reports.event_length(event)
end
def print_result
days_worked = @days.length
Reports.print("Weekend work", "%s days; Total events: %s; total hours: %0.2f" % [days_worked, @count, @hours])
end
end
end
class Calendar
attr_reader :credentials, :service, :id, :current_reports
##
# Ensure valid credentials, either by restoring from the saved credentials
# files or intitiating an OAuth2 authorization. If authorization is required,
# the user's default browser will be launched to approve the request.
#
# @return [Google::Auth::UserRefreshCredentials] OAuth2 credentials
def authorize
FileUtils.mkdir_p(File.dirname(SETTINGS.credentials_path))
client_id = Google::Auth::ClientId.from_file(SETTINGS.client_secrets_path)
token_store = Google::Auth::Stores::FileTokenStore.new(file: SETTINGS.credentials_path)
authorizer = Google::Auth::UserAuthorizer.new( client_id, SETTINGS.scope, token_store)
user_id = 'default'
credentials = authorizer.get_credentials(user_id)
if credentials.nil?
url = authorizer.get_authorization_url(
base_url: SETTINGS.oob_uri)
puts "Open the following URL in the browser and enter the " +
"resulting code after authorization"
raise("try 'open' here")
puts url
code = gets
credentials = authorizer.get_and_store_credentials_from_code(
user_id: user_id, code: code, base_url: SETTINGS.oob_uri)
end
@credentials = credentials
end
def calendar_by_name(name)
service.list_calendar_lists().items.each do |cal|
return cal if cal.summary == name
end
raise("Could not find calendar #{name}")
end
def calendar_id(id)
if id == "primary"
return id
else
return calendar_by_name(id).id
end
end
def download_data(period)
FileUtils.mkdir_p(File.dirname(SETTINGS.data_dir))
end
# Find all of the events for a given week, and send them to the caller.
# Assume weeks start on a Sunday and end on a Monday
def events_by_week(total)
week_dates(total).each do |week, start_time, end_time|
init_week(week, start_time)
response = service.list_events(id,
single_events: true,
order_by: 'startTime',
time_min: start_time.to_time.iso8601,
time_max: end_time.to_time.iso8601)
puts "No events found" if response.items.empty?
response.items.each do |event|
yield event
end
end
end
def initialize(name)
@id = calendar_id(name)
init_service()
@reports = []
@weekly_data = []
end
def init_service
# Initialize the API
service = Google::Apis::CalendarV3::CalendarService.new
service.client_options.application_name = SETTINGS.application_name
service.authorization = authorize() or raise("Failed to get credentials")
@service = service
end
def init_week(week, date)
reports = @reports.collect { |k| k.new }
@weekly_data.push([
week,
date,
reports
])
# Store the current week's reports so that they get processed by default
@current_reports = reports
end
def list_calendars
calendars = service.list_calendar_lists()
puts calendars.items.collect { |cal|
if cal.description
"#{cal.summary}: #{cal.description}"
else
cal.summary
end
}.sort
exit
end
def process(count)
events_by_week(count) do |event|
current_reports.each do |report|
report.process_event(event)
end
end
end
# Process each of our reports, and print their output
def report(count)
process(count)
@weekly_data.each do |week, date, reports|
puts "Week #{week}: #{date.to_date}"
reports.each do |report|
report.print_result
end
end
end
# Add one or more reports to run. Must be the class of a report
def report_on(*list)
list.each do |klass|
@reports.push(klass)
end
end
# This is basically a demo method. Currently unused.
def test
# Fetch the next 10 events for the user
calendar_id = 'primary'
response = service.list_events(calendar_id,
max_results: 10,
single_events: true,
order_by: 'startTime',
time_min: Time.now.iso8601)
puts "Upcoming events:"
puts "No upcoming events found" if response.items.empty?
response.items.each do |event|
start = event.start.date || event.start.date_time
puts "- #{event.summary} (#{start})"
end
end
# This method is quite prone to error. That is, it should work in most cases, but I'd be shocked it if it worked in
# all of them. At the least, it could use some basic testing for some of the more common failure modes.
# It could also probably be done more with declaration and less with math. I assume.
def week_dates(distance)
# Find the most recent saturday
recent_saturday = DateTime.now
until recent_saturday.saturday?
recent_saturday = recent_saturday.prev_day
end
dates = []
distance.to_i.times do |i|
# Now find $distance saturdays back
saturday = recent_saturday - (7 * i)
raise("Moved back saturdays to a non-saturday, somehow: #{saturday}") unless saturday.saturday?
# Now make a new date, with the above date's day/month/etc, but with the time as 23:59:59
end_time = DateTime.new(saturday.year, saturday.month, saturday.mday, 23, 59, 59)
#previous_sunday = (end_time.to_time - (86400 * 6)).to_date
previous_sunday = (end_time - 6)
raise("Somehow six days ago wasn't a sunday? #{previous_sunday}") unless previous_sunday.sunday?
start_time = DateTime.new(previous_sunday.year, previous_sunday.month, previous_sunday.mday, 0, 0, 01)
dates.unshift([i, start_time, end_time])
end
return dates
end
end
require 'optparse'
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: download.rb [options] <calendar> <number of weeks>"
opts.on("-h", "--help", "Print this help string") do |v|
puts opts
exit
end
end.parse!
calendar_name = ARGV.shift || raise("Must provide calendar")
num_weeks = ARGV.shift || raise("Must provide number of weeks")
calendar = Calendar.new(calendar_name)
calendar.report_on(Reports::OpenTime, Reports::BusyEvenings, Reports::WeekendWork)
calendar.report(num_weeks)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment