#!/usr/bin/ruby -w require 'rubygems' require 'tzinfo' require "date" require "net/http" require "net/https" require "rexml/document" require "parsepalmdatebook" require "cgi/session" require "cgi" # Configuration options # TODO $LogStatus = true # Constants ApplicationName = 'gregstoll-updatefrompalm-1.0' CalendarService = 'cl' AuthenticationPath = '/accounts/ClientLogin' AuthSubSessionTokenPath = '/accounts/AuthSubSessionToken' AuthSubRevokeTokenPath = '/accounts/AuthSubRevokeToken' # There seems to be a problem where if you delete entries too fast, they # don't all get deleted. So we sleep this number of seconds after each one. SleepTimeBetweenDeletes = 1 # TODO - use TimeZone RepeatSpecFooter = < 0) logger.logText("Adjusting endDate forward") @endDate = @endDate + 1 end toReturn = " \n" toReturn += "#{ REXML::Text::normalize(@title) }\n" if (@note) toReturn += "#{ REXML::Text::normalize(@note) }\n" else toReturn += "\n" end toReturn += "#{ googleName }#{ googleEmail }\n" toReturn += "\n" toReturn += "\n" if (@repeat == nil) then if (@startDate.instance_of?(DateTime)) then startDateTimeZone = timeZone.local_to_utc(@startDate) endDateTimeZone = timeZone.local_to_utc(@endDate) toReturn += "" else # All-day event toReturn += "" end if ((@alarmNumber != 0) and @alarmUnit) toReturn += "" end toReturn += "\n" else # Handle the repeat case #print "doing repeat: spec is " #puts self.to_s repeatSpec = '' repeatSpec += "DTSTART;TZID=America/Chicago:" + @startDate.to_s_ical + "\n" #puts "endDate is #@endDate, startDate is #@startDate" if (@startDate.instance_of?(DateTime)) then repeatSpec += "DURATION:PT" + ((@endDate - @startDate)*86400).to_i.to_s + "S\n" else # All-day event repeatSpec += "DURATION:PT86400S\n" end repeatSpec += 'RRULE:FREQ=' # FFV - move this into Repeat class if (@repeat.repeatType == :daily) then repeatSpec += 'DAILY;' elsif (@repeat.repeatType == :weekly) then repeatSpec += 'WEEKLY;BYDAY=' @repeat.repeatDays.each {|day| repeatSpec += DatebookEntry::getICalDay(day) + ','} repeatSpec.chop! repeatSpec += ';' else logger.logError("Error - don't know repeat type #@repeatType", true) end repeatSpec += 'UNTIL=' + @repeat.endDate().to_s_ical + "\n" repeatSpec += RepeatSpecFooter #puts "Got repeat spec: #{ repeatSpec }" toReturn += "\n" + repeatSpec + "\n" end toReturn += "\n" return toReturn end end end module UpdateGoogleCalendar class TextLogger def logError(message, isFatal) if isFatal raise message else puts message end end def logGettingPage(pageNumber) puts "Getting page #{pageNumber} of existing calendar..." end def logNumberToDeleteAndAdd(numDelete, numAdd) puts "Will delete #{numDelete} entries and add #{numAdd} entries" end def logDeleted(num=1) print "x" * num $stdout.flush end def logAdded(num=1) print "-" * num $stdout.flush end def logText(str) puts str end def logDone puts "Done!" end end class SessionLogger def initialize(session) @session = session @logStatus = $LogStatus if (@logStatus) @logFile = File.new('/tmp/datebooklog1', 'w') end end def logMessage(message) if (@logStatus) @logFile << message @logFile.flush end end def doLog logMessage(@session['status'] + "\n") end def logError(message, isFatal) existingStatus = REXML::Document.new @session['status'] # See if we have an error field already - if not, create it. errorField = existingStatus.root.elements["errors"] if not errorField errorField = REXML::Element.new("errors") existingStatus.root.add_element(errorField) end errorField.text = "" if not errorField.text errorField.text = errorField.text + message # Truncate to 10000 characters for sanity errorField.text = errorField.text[0..9999] if (isFatal) existingStatus.root.add_element(REXML::Element.new("done")) successField = REXML::Element.new("success") successField.text = "0" existingStatus.root.add_element(successField) end @session['status'] = existingStatus.to_s @session.update doLog if (isFatal) raise "" end end def logGettingPage(pageNumber) existingStatus = REXML::Document.new @session['status'] existingStatus.root.elements.each("text") do |textElem| textElem.text = "Getting page #{pageNumber} of existing calendar..." end @session['status'] = existingStatus.to_s @session.update doLog end def logNumberToDeleteAndAdd(numDelete, numAdd) existingStatus = REXML::Document.new @session['status'] numberData = {"delete" => numDelete, "add" => numAdd} ["delete", "add"].each do |deleteOrAdd| baseField = REXML::Element.new(deleteOrAdd) existingStatus.root.add_element(baseField) totalField = REXML::Element.new("total") totalField.text = numberData[deleteOrAdd].to_s currentField = REXML::Element.new("current") currentField.text = "0" baseField.add_element(totalField) baseField.add_element(currentField) end @session['status'] = existingStatus.to_s @session.update doLog end def logDeleted(num=1) existingStatus = REXML::Document.new @session['status'] existingStatus.root.elements.each("delete/current") do |currentElem| currentElem.text = currentElem.text.to_i + num end existingStatus.root.elements.each("text") do |textElem| textElem.text = "Deleting entries..." end @session['status'] = existingStatus.to_s @session.update doLog end def logAdded(num=1) existingStatus = REXML::Document.new @session['status'] existingStatus.root.elements.each("add/current") do |currentElem| currentElem.text = currentElem.text.to_i + num end existingStatus.root.elements.each("text") do |textElem| textElem.text = "Adding entries..." end @session['status'] = existingStatus.to_s @session.update doLog end # Text messages are informational only, so we don't record them # for now. def logText(str) end def logDone existingStatus = REXML::Document.new @session['status'] doneField = REXML::Element.new('done') existingStatus.root.add_element(doneField) successField = REXML::Element.new('success') existingStatus.root.add_element(successField) successField.text = "1" existingStatus.root.elements.each("text") do |textElem| textElem.text = "Done!" end @session['status'] = existingStatus.to_s @session.update doLog if (@logStatus) @logFile.close end end end class ReadGoogleCalendarEntry attr_accessor :title, :startDate, :endDate, :removeURL, :note, :logger, :alarmNumber, :alarmUnit def initialize(entryXml, logger) setFromXML(entryXml) @logger = logger self end def to_s output = "#@title (#@startDate" output = output + " to #@endDate" if @endDate output = output + ")" output = output + " (NOTE: #@note)" if @note output = output + " unit #@alarmUnit number #@alarmNumber" output end # FFV - please please make this an operator or something def isEqual(other, timeZone) #if (other.title == @title) then # @logger.logText("Titles are equal (#@title) - this is #{self.to_s}, other is #{other.to_s}") #end if (other.title == @title and other.note == @note and (other.alarmNumber == 0 or (ParsePalmDatebook::Constants::PALMUNITTOMINS[other.alarmUnit] * other.alarmNumber) == (ParsePalmDatebook::Constants::GCALUNITTOMINS[@alarmUnit] * @alarmNumber))) if (other.startDate.instance_of?(DateTime)) then otherStartDate = timeZone.local_to_utc(other.startDate) else otherStartDate = other.startDate end if (other.endDate != nil and other.endDate.instance_of?(DateTime)) then otherEndDate = timeZone.local_to_utc(other.endDate) else otherEndDate = other.endDate end # For all-day events, the other endDate will be nil if it # doesn't repeat, so account for that case. equal = (otherStartDate == @startDate) and ((otherEndDate == @endDate) or ((otherEndDate == nil) and (otherStartDate.instance_of?(Date)))) if (not equal) then # Only print if their dates are the same. if (otherStartDate.year() == @startDate.year() and otherStartDate.month() == @startDate.month() and otherStartDate.day() == @startDate.day() and otherEndDate.year() == @endDate.year() and otherEndDate.month() == @endDate.month() and otherEndDate.day() == @endDate.day()) then @logger.logText("Titles are equal (#@title) - other sd is #{ otherStartDate }, this sd is #@startDate, other ed is #{ otherEndDate }, this ed is #@endDate") end end equal else false end end def setFromXML(entryXml) @alarmNumber = 0 @alarmUnit = nil entryXml.elements.each("gd:when") do |whenElem| startString = whenElem.attribute('startTime').to_s if (startString.include?('T')) then @startDate = DateTime.parse(startString) @startDate = @startDate.new_offset(0) else @startDate = Date.parse(startString) end endString = whenElem.attribute('endTime').to_s if (endString.include?('T')) then @endDate = DateTime.parse(endString) @endDate = @endDate.new_offset(0) else @endDate = Date.parse(endString) end whenElem.elements.each("gd:reminder") do |reminderElem| reminderElem.attributes.each { |attribute, value| @alarmUnit = attribute; @alarmNumber = value.to_i } end end entryXml.elements.each("title") do |titleElem| @title = REXML::Text::unnormalize(titleElem.collect { |textElem| textElem.to_s }.join()) end @note = nil entryXml.elements.each("content") do |noteElem| @note = REXML::Text::unnormalize(noteElem.collect { |textElem| textElem.to_s }.join()) @note = nil if @note == "" end entryXml.elements.each("link") do |linkElem| if (linkElem.attribute('rel').to_s == "edit") then @removeURL = linkElem.attribute('href').to_s end end # Corrections. # If this is an all-day event, it has it ending on the next day, # which is different from our parsing, so correct it. if (@endDate.instance_of?(Date)) then @endDate -= 1 end end end class UpdateGoogleCalendar def initialize(email, name, privateFeed, timeZoneString, logger) @logger = logger @authCode = nil @authToken = nil @existingEntries = [] @httpsecure = Net::HTTP.new('www.google.com',Net::HTTP.https_default_port) @httpsecure.use_ssl = true @httpsecure.verify_mode = OpenSSL::SSL::VERIFY_NONE @http = Net::HTTP.new('www.google.com',Net::HTTP.http_default_port) @googleEmail = email @googleName = name @googleFeedPrivate = UpdateGoogleCalendar::removeHostFromURL(privateFeed, @logger) @timeZone = TZInfo::Timezone.get(timeZoneString) if (@googleEmail == nil or @googleEmail == "") @logger.logError("Need to provide an email address for events!", true) end if (@googleName == nil or @googleName == "") @logger.logError("Need to provide a name for events!", true) end end def UpdateGoogleCalendar.removePrivateCookie(url, logger) if (url =~ /^(.*?)\/private-.*?\/(.*)$/) url = $1 + "/private/" + $2 else logger.logText "Warning: couldn't remove private cookie from #{url}" end if (url =~/^(.*)\/basic$/) url = $1 + "/full" end url end def UpdateGoogleCalendar.removeHostFromURL(url, logger) if (url =~ /^http(?:s?):\/\/www\.google\.com(.*)$/) return $1 else logger.logText "Warning: couldn't remove google host from #{url}" return url end end def deleteURL(url) headers = { 'Referer' => '', 'Content-Type' => 'application/atom+xml', 'Authorization' => self.getAuthorizationString, 'User-Agent' => ApplicationName } urlToDelete = UpdateGoogleCalendar::removeHostFromURL(url, @logger) #puts "DELETEing #{ urlToDelete }" response = @http.delete(urlToDelete, headers) if (response.code == "302") location = response["location"] response = @http.delete(location, headers) end if (response.code == "200") @logger.logDeleted else @logger.logError("Got unexpected response from deleting URL (expected 200, got #{ response.code })", false) end end def doAuthentication(email, password, authToken) if (authToken == nil) then data = "Email=#{CGI::escape(email)}&Passwd=#{CGI::escape(password)}&service=#{CGI::escape(CalendarService)}&source=#{CGI::escape(ApplicationName)}" headers = { 'Referer' => '', 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => ApplicationName } res = @httpsecure.post(AuthenticationPath, data, headers) do |response| response.split("\n").each do |line| @authCode = $1 if (line =~ /^Auth=(.*)\n?$/) end end if (res.code != "200") @logger.logError("Couldn't authenticate with GoogleLogin (got #{res.code})", true) end @logger.logText "authcode is #@authCode" else # Get a session token. headers = { 'Referer' => '', 'User-Agent' => ApplicationName, 'Content-Type' => 'application/x-www-form-urlencoded', 'Authorization' => "AuthSub token=\"#{authToken}\"" } response = @httpsecure.get(AuthSubSessionTokenPath, headers) if (response.code != "200" and response.code != "302") @logger.logError("Couldn't get session token to authenticate with AuthSub (got #{response.code})", true) else if (response.code == "302") @logger.logText "Got redirect" location = response['location'] response = @httpsecure.get(location, headers) if (response.code != "200") @logger.logError("Couldn't get session token redirect to authenticate with AuthSub after redirect (got #{response.code})", true) end end keyVals = response.body.split("\n") keyVals.each do |keyVal| if (keyVal.starts_with?('Token=')) then @authToken = keyVal[keyVal.index('=') + 1, keyVal.length] end end @logger.logText "authToken is #@authToken" end end end def revokeAuthentication if (@authToken != nil) headers = { 'Referer' => '', 'User-Agent' => ApplicationName, 'Content-Type' => 'application/x-www-form-urlencoded', 'Authorization' => self.getAuthorizationString, } response = @httpsecure.get(AuthSubRevokeTokenPath, headers) if (response.code != "200") @logger.logError("Couldn't revoke (got #{response.code})", false) else @authToken = nil end end end def getAuthorizationString if (@authCode != nil) "GoogleLogin auth=#@authCode" else "AuthSub token=\"#@authToken\"" end end def getExistingEntries headers = { 'Referer' => '', 'Content-Type' => 'application/atom+xml', 'Authorization' => self.getAuthorizationString, 'User-Agent' => ApplicationName } @logger.logText("authorization string is " + self.getAuthorizationString) location = UpdateGoogleCalendar::removePrivateCookie(@googleFeedPrivate, @logger) moreToGet = true pageNumber = 1 while moreToGet @logger.logGettingPage(pageNumber) moreToGet = false response = @http.request_get(location, headers) if (response.code == "302") then location = response['location'] response = @http.request_get(location, headers) end if (response.code != "200") @logger.logError("Got unexpected response when getting existing entry page #{location} (expected 200, got #{ response.code }); quitting", true) end eventXml = REXML::Document.new(response.body) #eventXml.write($stdout, 1) gotEvent = false eventXml.elements.each("*/entry") do |entryElem| @existingEntries.push(ReadGoogleCalendarEntry.new(entryElem, @logger)) gotEvent = true end # check if there's another page if we had events on this page if gotEvent then eventXml.elements.each("feed/link") do |linkElem| if (linkElem.attribute('rel').to_s == 'next') then location = UpdateGoogleCalendar::removeHostFromURL(REXML::Text::unnormalize(linkElem.attribute('href').to_s), @logger) moreToGet = true end end end pageNumber += 1 end end def deleteAllExistingEntries @logger.logText("Deleting all #{@existingEntries.length} entries") @logger.logNumberToDeleteAndAdd(@existingEntries.length, 0) # Now delete the entries to delete. @existingEntries.each do |entryToDelete| deleteURL(entryToDelete.removeURL) sleep(SleepTimeBetweenDeletes) end end def doUpload(fileName) entries = ParsePalmDatebook.getAllEntries(fileName, false) newEntries = [] entries.each do |entry| newEntries = newEntries + entry.expandRepeats(false) end entries = newEntries # Compare these entries to the ones that already exist, and remove # duplicates. # FFV - this is ugly ugly indicesToDelete = [] firstDelete = @existingEntries.length firstAdd = entries.length @existingEntries.each_index do |existingIndex| existingEntry = @existingEntries[existingIndex] entries.each_index do |entryIndex| if (existingEntry.isEqual(entries[entryIndex], @timeZone)) then indicesToDelete.push(existingIndex) entries.delete_at(entryIndex) break end end end indicesToDelete.each {|i| @existingEntries[i] = nil} @existingEntries.compact! @logger.logText("#{ firstDelete } events in calendar, #{ firstAdd } events in palm") @logger.logNumberToDeleteAndAdd(@existingEntries.length, entries.length) # Now delete the entries to delete. @existingEntries.each do |entryToDelete| deleteURL(entryToDelete.removeURL) sleep(SleepTimeBetweenDeletes) end # and add the entries to add, if there are any. if (entries.length == 0) return end googleEvents = entries.collect do |entry| entry.getGoogleEvent(@googleEmail, @googleName, @timeZone, @logger) end googleEvents.compact! headers = { 'Referer' => '', 'Content-Type' => 'application/atom+xml', 'Authorization' => self.getAuthorizationString, 'User-Agent' => ApplicationName } response = @http.post(UpdateGoogleCalendar::removePrivateCookie(@googleFeedPrivate, @logger), googleEvents[-1], headers) location = UpdateGoogleCalendar::removePrivateCookie(@googleFeedPrivate, @logger) if (response.code == "302") then location = response['location'] #response.each {|header,value| puts "Header: #{ header }, value: #{ value }"} else @logger.logError("Got unexpected response when setting up POST to add events (expected 302, got #{ response.code }); quitting", true) end googleEvents.each do |event| response = @http.post(location, event, headers) if (response.code == "201") then @logger.logAdded else @logger.logError("Got unexpected response when adding (expected 201, got #{ response.code }); (location is #{ location })", false) end end end end #allEntries = ParsePalmDatebook.getAllEntries(datebookFilename) #allEntries.each do |e| # if (e.repeat != nil) then # expanded = e.expandRepeats # puts "Expanded #{ e.to_s } to #{ expanded.to_s }" # end #end def UpdateGoogleCalendar.updateGoogleCalendar(datebookFilename, params, logger) ugc = UpdateGoogleCalendar.new(params['GoogleEmail'], params['GoogleName'], params['GoogleFeedPrivate'], params['TimeZoneString'], logger) ugc.doAuthentication(params['Username'], params['Password'], params['authToken']) ugc.getExistingEntries ugc.doUpload(datebookFilename) ugc.revokeAuthentication logger.logDone end if __FILE__ == $0 then require "parseconfigfile" params = ParseConfigFile::parseConfigFile('.logininfo') UpdateGoogleCalendar.updateGoogleCalendar('datebook.txt', params, TextLogger.new) end end