#!/usr/bin/env ruby
# Filename:     google-cal
# Author:       David Ljung Madison <DaveSource.com>
# See License:  http://MarginalHacks.com/License/
# Description:  Google calendar command-line interface

PROGNAME = File.basename($0)

# Requires
require 'resolv-replace'  # Otherwise google can take a *very* long time
REQUIRES = [
	['googleauth','sudo apt-get install ruby-googleauth'],
	['googleauth/stores/file_token_store','sudo apt-get install ruby-googleauth'],
	['google/apis/calendar_v3','sudo apt-get install ruby-google-api-client'],
#	['googleauth','sudo gem install googleauth'],
#	['googleauth/stores/file_token_store','sudo gem install googleauth'],
#	['google/apis/calendar_v3','sudo gem install google-api-client'],
	]

# Credentials
CLIENT_SECRETS_PATH = File.join(Dir.home, '.google', 'calendar-cli.secret.json')
CREDENTIALS_PATH = File.join(Dir.home, '.google', 'calendar-ruby-credentials.yaml')
SCOPE = 'https://www.googleapis.com/auth/calendar'
APPLICATION_NAME = 'Calendar CLI'
OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'

# Ex:  48*HOURS
MINUTES = 60
HOURS = 60*MINUTES
DAYS = 24*HOURS

##################################################
# Require testing
##################################################
def fatal(msg=nil)
	$stderr.puts msg if msg
	exit -1
end

def canRequire(what)
  begin
  	require what
  rescue LoadError => e
		# It might have been another library that it required..
		raise e unless e.message.match(what)
  	return false
  end
  return true
end

module Kernel
	def suppress_warn
		original_verbosity = $VERBOSE
		$VERBOSE = nil
		result = yield
		$VERBOSE = original_verbosity
		return result
	end
end

def reqRequires(reqs=REQUIRES)
	reqs.each { |req|
		(r,fix,version,msg) = req
		next if canRequire(r)
		msg ||= "Missing ruby requirement [#{r}].  Fix with:"
		fatal("\n#{msg}\n% #{fix}\n")
	}
end
reqRequires


##################################################
# Calendar authorization
##################################################
def how_to_authorize
	$stderr.puts <<-NOCLIENT

Welcome!

To use '#{PROGNAME}' You need to first register an application with 
Google that has access to the calendar API:

Create or select a project here:
  https://console.developers.google.com/flows/enableapi?apiid=calendar

Select "Continue", "Go to credentials"

On the "Add credentials" page click "Cancel" at the bottom

Select "OAuth consent screen" and select an email address
Set Product name to "#{PROGNAME}" and click 'Save'

Select "Credentials" tab, click "Create credentials" -> "OAuth client ID"

Choose "Application type" of "Other" or "Desktop App" and enter name "#{PROGNAME}"

Select "Create" and then "OK" then on the far right click on the
download button (down arrow) next to the client ID.

Save/move this file to:

  #{CLIENT_SECRETS_PATH}

You then need to either verify the app or else go to the "Audience" screen
and make sure "Publishing Status" is "Testing" and then under "Test Users" click
on "+ Add Users" and add your google account.

Then you can restart this script, and it will have you do a
one-time authorization of the application using a web browser,
then you are ready to use '#{PROGNAME}'

	NOCLIENT
	exit -1
end

def authorize
	FileUtils.mkdir_p(File.dirname(CREDENTIALS_PATH))

	# Step 1 at https://developers.google.com/google-apps/calendar/quickstart/ruby
	how_to_authorize() unless File.exist?(CLIENT_SECRETS_PATH)

	client_id = Google::Auth::ClientId.from_file(CLIENT_SECRETS_PATH)
	token_store = Google::Auth::Stores::FileTokenStore.new(file: CREDENTIALS_PATH)
	authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store)
	user_id = 'default'

	# Try to get credentials
	credentials = nil
	begin
		credentials = authorizer.get_credentials(user_id)
	rescue => e
		$stderr.puts "Error: #{e.message}"
		$stderr.puts "\nConsider removing the file '#{CREDENTIALS_PATH}' and re-authorizing" if File.exist?(CREDENTIALS_PATH)
		exit -1
	end

	# None found, need to authorize
	if credentials.nil?
		url = authorizer.get_authorization_url(
			base_url: OOB_URI)
		$stderr.puts "\nGo to the following webpage to authorize Google Calendar:\n\n#{url}"
		$stderr.print "\nEnter token received from authorization: "
		code = $stdin.gets.chomp
		credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code, base_url: OOB_URI)
	end

	credentials
end

def authorizeCalendar(opt)
	opt[:cal] = Google::Apis::CalendarV3::CalendarService.new
	opt[:cal].client_options.application_name = APPLICATION_NAME
	opt[:cal].authorization = authorize
	opt[:cal]
end

$REAUTH = 0
def reauthorizeCalendar(opt)
	usage("Failed calendar authorization and attempt to reauthorize??") unless File.delete CREDENTIALS_PATH and $REAUTH==0
	puts "Calendar authorization failed - attempt to reauthorize..\n"
	$REAUTH += 1
	authorizeCalendar(opt)
end

##################################################
# Utils
##################################################
def usage(*msg)
	msg.each { |m| $stderr.puts m }
	$stderr.puts <<-USAGE

Usage: #{PROGNAME} <command> [args..]
	Command-line interface to google calendar

Commands:
  cals              List calendars
  list [cal] [num]  Upcoming events
  add [ev]          Add event:
    add <facebook event>
    add <cal> <facebook event>
    add <quick text>

Calendars can be specified a number of ways:
  primary           The default calendar
  <calendar ID>     I.e.:  32aB1g@group.calendar.google.com
  <name>            I.e.:  Holidays
  <regex>           I.e.:  Ho.*day

	USAGE
	exit -1
end

def hms(hms)
	str = hms.dup
	t = 0
	t += $1.to_i*DAYS while str.sub!(/(\d+)d/i,'')
	t += $1.to_i*HOURS while str.sub!(/(\d+)h/i,'')
	t += $1.to_i while str.sub!(/(\d+)s/i,'')
	t += $1.to_i*MINUTES while str.sub!(/(\d+)m?/i,'')
	$stderr.puts "[Warning] Trouble parsing time [#{hms}]" if str =~ /\S/
	t
end

def tohms(t)
	t = t.to_i
	return "now" if t<8
	days = t/DAYS; t-=DAYS*days
	hours = t/HOURS; t-=HOURS*hours
	mins = t/MINUTES; t-=MINUTES*mins
	ret = ""
	ret += "#{days}d" if days>0
	ret += "#{hours}h" if hours>0
	ret += "#{mins}m" if mins>0
	ret += "#{t}s" if t>0 && days==0 && hours==0 && mins<2
	ret
end

def parseArgs
	opt = Hash.new
	opt[:D] = 0
	cmd = nil
	args = []

	loop {
		if (arg=ARGV.shift)==nil then break
		elsif arg == '-h' then usage
		elsif cmd then args.push(arg)
		else cmd = arg
		end
	}

	usage() unless cmd

	# Authorize
	authorizeCalendar(opt)

	[opt,cmd,*args]
end

##################################################
# Facebook event info
##################################################
class String
	# Combination of force_encoding and encode
	def force_encode(encoding)
		begin
			self.encode(encoding)
		rescue
			self.force_encoding(encoding)
		end
	end
	def force_utf8
		force_encode('UTF-8')
	end
end

CONVERT_ENTITIES = {
	"#039" => "'",
	"copy"    => '©', # Copyright
	"die"   => '¨', # Diæresis / Umlaut
	"laquo"   => '«', # Left angle quote, guillemot left
	"not"   => '¬', # Not sign
	"ordf"    => 'ª', # Feminine ordinal
	"sect"    => '§', # Section sign
	"um"    => '¨', # Diæresis / Umlaut
	"AElig"   => 'Æ', # Capital AE ligature
	"Aacute"  => 'Á', # Capital A, acute accent
	"Acirc"   => 'Â', # Capital A, circumflex
	"Agrave"  => 'À', # Capital A, grave accent
	"Aring"   => 'Å', # Capital A, ring
	"Atilde"  => 'Ã', # Capital A, tilde
	"Auml"    => 'Ä', # Capital A, diæresis / umlaut
	"Ccedil"  => 'Ç', # Capital C, cedilla
	"ETH"   => 'Ð', # Capital Eth, Icelandic
	"Eacute"  => 'É', # Capital E, acute accent
	"Ecirc"   => 'Ê', # Capital E, circumflex
	"Egrave"  => 'È', # Capital E, grave accent
	"Euml"    => 'Ë', # Capital E, diæresis / umlaut
	"Iacute"  => 'Í', # Capital I, acute accent
	"Icirc"   => 'Î', # Capital I, circumflex
	"Igrave"  => 'Ì', # Capital I, grave accent
	"Iuml"    => 'Ï', # Capital I, diæresis / umlaut
	"Ntilde"  => 'Ñ', # Capital N, tilde
	"Oacute"  => 'Ó', # Capital O, acute accent
	"Ocirc"   => 'Ô', # Capital O, circumflex
	"Ograve"  => 'Ò', # Capital O, grave accent
	"Oslash"  => 'Ø', # Capital O, slash
	"Otilde"  => 'Õ', # Capital O, tilde
	"Ouml"    => 'Ö', # Capital O, diæresis / umlaut
	"THORN"   => 'Þ', # Capital Thorn, Icelandic
	"Uacute"  => 'Ú', # Capital U, acute accent
	"Ucirc"   => 'Û', # Capital U, circumflex
	"Ugrave"  => 'Ù', # Capital U, grave accent
	"Uuml"    => 'Ü', # Capital U, diæresis / umlaut
	"Yacute"  => 'Ý', # Capital Y, acute accent
	"aacute"  => 'ß', # Small a, acute accent
	"acirc"   => 'â', # Small a, circumflex
	"acute"   => '´', # Acute accent
	"aelig"   => 'æ', # Small ae ligature
	"agrave"  => 'à', # Small a, grave accent
	"amp"   => '&', # Ampersand
	"aring"   => 'å', # Small a, ring
	"atilde"  => 'ã', # Small a, tilde
	"auml"    => 'ä', # Small a, diæresis / umlaut
	"brkbar"  => '¦', # Broken vertical bar
	"brvbar"  => '¦', # Broken vertical bar
	"ccedil"  => 'ç', # Small c, cedilla
	"cedil"   => '¸', # Cedilla
	"cent"    => '¢', # Cent sign
	"curren"  => '¤', # General currency sign
	"deg"   => '°', # Degree sign
	"divide"  => '÷', # Division sign
	"eacute"  => 'é', # Small e, acute accent
	"ecirc"   => 'ê', # Small e, circumflex
	"egrave"  => 'è', # Small e, grave accent
	"eth"   => 'ð', # Small eth, Icelandic
	"euml"    => 'ë', # Small e, diæresis / umlaut
	"frac12"  => '½', # Fraction one-half
	"frac14"  => '¼', # Fraction one-fourth
	"frac34"  => '¾', # Fraction three-fourths
	"gt"    => '>', # Greater than
	"hibar"   => '¯', # Macron accent
	"iacute"  => 'í', # Small i, acute accent
	"icirc"   => 'î', # Small i, circumflex
	"iexcl"   => '¡', # Inverted exclamation
	"igrave"  => 'ì', # Small i, grave accent
	"iquest"  => '¿', # Inverted question mark
	"iuml"    => 'ï', # Small i, diæresis / umlaut
	"lt"    => '<', # Less than
	"macr"    => '¯', # Macron accent
	"micro"   => 'µ', # Micro sign
	"middot"  => '·', # Middle dot
	"nbsp"    => ' ', # Non-breaking Space
	"ntilde"  => 'ñ', # Small n, tilde
	"oacute"  => 'ó', # Small o, acute accent
	"ocirc"   => 'ô', # Small o, circumflex
	"ograve"  => 'ò', # Small o, grave accent
	"ordm"    => 'º', # Masculine ordinal
	"oslash"  => 'ø', # Small o, slash
	"otilde"  => 'õ', # Small o, tilde
	"ouml"    => 'ö', # Small o, diæresis / umlaut
	"para"    => '¶', # Paragraph sign
	"plusmn"  => '±', # Plus or minus
	"pound"   => '£', # Pound sterling
	"quot"    => '"', # Quotation mark
	"raquo"   => '»', # Right angle quote, guillemot right
	"reg"   => '®', # Registered trademark
	"shy"   => '­', # Soft hyphen
	"sup1"    => '¹', # Superscript one
	"sup2"    => '²', # Superscript two
	"sup3"    => '³', # Superscript three
	"szlig"   => 'ß', # Small sharp s, German sz
	"thorn"   => 'þ', # Small thorn, Icelandic
	"times"   => '×', # Multiply sign
	"uacute"  => 'ú', # Small u, acute accent
	"ucirc"   => 'û', # Small u, circumflex
	"ugrave"  => 'ù', # Small u, grave accent
	"uuml"    => 'ü', # Small u, diæresis / umlaut
	"yacute"  => 'ý', # Small y, acute accent
	"yen"   => '¥', # Yen sign
	"yuml"    =>'\255', # Small y, diæresis / umlaut
	}

def convert_entity(str)
	CONVERT_ENTITIES[str] || "&#{str};"
end

def unHTML(str)
	str.gsub(/<br \/>/,"\n").gsub(/<[^>]+>/,'').gsub(/\&([^\&]+);/){convert_entity($1)}
end

QUOTE = '(\'|&#039;)'

def facebookEventInfo(url)
	title = ''
	date = nil
	startDate = nil
	endDate = nil
	addr = nil
	desc = ''
	altdesc = ''
	altaltdesc = ''
	unavail = false

	begin
		client = HTTPClient.new
		client.get_content(url).split("\n").each { |l|
			unavail = true if l.match(/(This content is currently unavailable|this page isn#{QUOTE}t available|this content isn#{QUOTE}t available right now)/i)
			title = $1.force_utf8 if l.match(/<title[^>]*>([^<]+)<\/title/)
			# Sometimes we can get startDate, which includes time
			if (l.match(/"startDate" content="([^"]+)"/) || l.match(/"startDate">([^<]+)</))
				begin
					startDate = Time.parse($1).to_i
				rescue ArgumentError
					nil
				end
			end
			# but sometimes not.  And end is only a date, unless we parse
			# all the possible <spans> and ways that facebook represents date/times
			# (such as "tomorrow at 8pm")
			l.gsub(/\/events\/calendar\?adjusted_ts=(\d+)/) {
				d = $1.to_i
				# Unfortunately calculating end time is very complicated, we just get the day
				date ? (endDate = d) : (date=d)
			}
			addr = unHTML($1) if l.match(/"event-permalink-location">(.+?)<\/a>/)
			addr = unHTML($2) if !addr && l.match(/Hide Map<\/a>(<\/div>|<div[^>]+>|<span[^>]+>)*([^<]+)/)
			desc += $1.force_utf8 if l.match(/code id="u_0_12">.*Details(.+?)<\/code/)
			altdesc += $1.force_utf8 if l.match(/class="text_exposed_root">(.+?)<span/)
			altdesc += $1.force_utf8 if l.match(/class="text_exposed_show">(.+?)<span class="text_exposed_hide">/)
			# Using the "fsl" span:  Unfortunately links have spans in the description, so we might cut short, look for span/di
			altaltdesc = $1.force_utf8 if l.match(/class="[^"]*fsl">(.+?)<\/span><\/div/)
		}
	rescue
		return [true]
	end

	desc = desc=='' ? (altdesc=='' ? altaltdesc : altdesc) : desc
	desc = unHTML(desc)
	desc.gsub!(/See More --\>$/,'')

	# Which date(s) to use?
	if startDate
		endDate ||= date
		date = startDate
	end
	endDate = (date+1*HOURS) if date && (!endDate || endDate<=date)

	#numDays = endDate ? ((endDate - date)/(24.0*60*60)).round : nil
	endDate ||= date

	unavail = false if date||addr||(desc&&!desc.empty?)
	[unavail,title,date,endDate,addr,desc]
end

##################################################
# Google Calendar
##################################################
# Methods:
#  get_calendar(cal, ...)
#  get_calendar_list(cal, ..)
#  get_event(cal,event_id)
#  import_event(cal,ev,...)
#  insert_calendar(...)
#  insert_calendar_list(...)
#  insert_event(cal,ev)
#  list_calendar_lists
#  list_events(cal)
#  list_event_instances(cal,ev)  # Repeating events
#  move_event(cal,ev,newcal)
#  patch_event(cal,ev_id,ev...)
#  update_event(cal,ev_id,ev...)
#  quick_add_event(cal,text)

def getCal(opt,name,required=true)
	return ['primary','primary'] if name=='primary' || !name

	cals = []
	begin
		opt[:cal].list_calendar_lists.items.each { |c|
			return [c.id,c.summary] if name==c.id || name.downcase==c.summary.downcase
			cals.push([c.id,c.summary])
		}
	rescue Signet::AuthorizationError
		reauthorizeCalendar(opt)
		return getCal(opt,name,required)
	end
	cals.each { |c| return c if c[1].match(/#{name}/i) }
	cals.each { |c| return c if c[0].match(/#{name}/i) }
	return nil unless required
	usage("Unknown calendar: [#{name}]")
end

def googleTime(t)
	Time.at(t).strftime("%Y-%m-%dT%H:%M:%S%z")
end

def googleEvent(ev)
	event = Google::Apis::CalendarV3::Event.new
	ev.keys.each { |key|
		keyset = key.to_s+'='
		ev[key] = { date_time: googleTime(ev[key]) } if key==:start || key==:end
		#puts "Set: #{key} to #{ev[key]}"
		event.send(keyset,ev[key])
	}
	#event.recurrence = [
	#	'RRULE:FREQ=DAILY;COUNT=2'
	#]
	event
end

def cals(opt,*args)
	cals = []
	len = 10
	opt[:cal].list_calendar_lists.items.each { |c|
		cals.push([c.id,c.summary])
		len = c.id.length if c.id.length>len
	}
	cals.sort_by { |c| [c[0].length,c[1].upcase] }.each { |c|
		puts "%-#{len}s  #{c[1]}" % c[0]
	}
end

def list(opt,calName=nil,max_results=nil)
	calName,max_results = nil,calName if !max_results && calName && calName.match(/^\d+$/)
	max_results||=15

	cal,name = getCal(opt,calName)
	begin
		response = opt[:cal].list_events(cal,
			max_results: max_results,
			single_events: true,
			order_by: 'startTime',
			time_min: Time.now.iso8601)
	rescue Google::Apis::ClientError
		usage("Unknown calendar: #{cal}")
	end

	puts "Upcoming events: [#{name}]"
	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

def isFB(arg)
	return false unless arg
	arg.match(/^http.*facebook.com/) ? true : false
end

def add(opt,*args)
	# Facebook add?
	## Doesn't work - requires FB login
	#if args[0].match(/facebook.com\/events\/(\d+)/)
	#	ical = 'https://www.facebook.com/events/ical/export/?eid='+$1
	#	..

	# Use my Facebook event page parsing code
	if isFB(args[0]) || isFB(args[1])
		calName,url = isFB(args[0]) ? [calName,args[0]] : args
		
		unavail,title,date,endDate,addr,desc = facebookEventInfo(url)
		usage("Couldn't read facebook event (possibly private?)\n  #{url}") if unavail
		puts "Adding facebook event \"#{title}\""
		puts "To calendar: #{calName}" if calName
		usage("Couldn't figure out date?") unless date
		usage("Couldn't figure out enddate?") unless endDate
		desc = url+"\n\n"+desc
		args = [title,date,endDate,addr,desc]
		#puts "HEY: #{title},#{date},#{endDate},#{addr},#{desc}"

		event = googleEvent(summary: title, start: date, end: endDate, description: desc, location: addr)

		begin
			cal,calName = getCal(opt,calName)
			response = opt[:cal].insert_event(cal,event)
		rescue => e
			usage("Failed to add event: #{event.summary}:\n  #{e}")
		end
		return
	end

	# Not a FB event, try quick add
	cal,name = getCal(opt,args[0],false)
	cal ? args.shift : (cal='primary')
	text = args.join(' ')
	puts "Quick add event: #{text}"
	puts "To calendar: #{name}" if name!='primary'
	begin
		response = opt[:cal].quick_add_event(cal,text)
	rescue => e
		usage("Failed to add event: #{text}:\n  #{e}")
	end
end

##################################################
# Main code
##################################################
def main
	opt,cmd,*args = parseArgs

	begin
		case cmd
		when 'cals'
			cals(opt,*args)
		when 'list'
			list(opt,*args)
		when 'add'
			add(opt,*args)
		else
			usage("Unknown command: #{cmd}")
		end
	rescue => e
		fatal(e.full_message) unless e.message.match(/auth.*failed/i)
		how_to_authorize
	end
end
main

