├── VERSION ├── .document ├── .env.default ├── test ├── mocks │ ├── login_with_auth_code_fail.json │ ├── login_with_refresh_token_success.json │ ├── create_calendar.json │ ├── 500.json │ ├── 403_forbidden.json │ ├── 403_unknown.json │ ├── 404.json │ ├── 410.json │ ├── successful_login.json │ ├── update_calendar.json │ ├── 403_daily_limit.json │ ├── 403_rate_limit.json │ ├── login_with_auth_code_success.json │ ├── 403_user_rate_limit.json │ ├── 409.json │ ├── 403_calendar_rate_limit.json │ ├── 401.json │ ├── 412.json │ ├── get_calendar.json │ ├── cancelled_events.json │ ├── 400.json │ ├── 403.json │ ├── empty_events.json │ ├── freebusy_query.json │ ├── create_quickadd_event.json │ ├── create_event.json │ ├── find_event_by_id.json │ ├── missing_date.json │ ├── find__all_day_event_by_id.json │ ├── query_events.json │ ├── repeating_events.json │ ├── find_calendar_list.json │ └── events.json ├── helper.rb └── test_google_calendar.rb ├── Gemfile ├── Guardfile ├── lib ├── google_calendar.rb └── google │ ├── calendar_list_entry.rb │ ├── calendar_list.rb │ ├── errors.rb │ ├── freebusy.rb │ ├── connection.rb │ ├── event.rb │ └── calendar.rb ├── .env.test ├── .travis.yml ├── .gitignore ├── Rakefile ├── LICENSE.txt ├── google_calendar.gemspec ├── Gemfile.lock ├── readme_code.rb └── README.rdoc /VERSION: -------------------------------------------------------------------------------- 1 | 0.6.4 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | CLIENT_ID= 2 | CLIENT_SECRET= 3 | REFRESH_TOKEN= 4 | CALENDAR_ID= 5 | ACCESS_TOKEN= 6 | REDIRECT_URL= 7 | -------------------------------------------------------------------------------- /test/mocks/login_with_auth_code_fail.json: -------------------------------------------------------------------------------- 1 | { 2 | "error_description": "Code was already redeemed.", 3 | "error": "invalid_grant" 4 | } -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Using google_calendar to manage dependencies 4 | gemspec 5 | gem "codeclimate-test-reporter", group: :test, require: nil -------------------------------------------------------------------------------- /test/mocks/login_with_refresh_token_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "ya29.hYjPO0uHt63uWr5qmQtMEReZEvILcdGlPCOHDy6quKPyEQaQQvqaVAlLAVASaRm_O0a7vkZ91T8xyQ", 3 | "token_type": "Bearer", 4 | "expires_in": 3600 5 | } -------------------------------------------------------------------------------- /test/mocks/create_calendar.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#calendar", 3 | "etag": "\"W8S50vTLOjlc76YTrehSZVQwSEE/ai1KY6CkM6KpTpfYTn2jy_Z5fsc\"", 4 | "id": "djg1ts9g20ih03ccb0fgsegkio@group.calendar.google.com", 5 | "summary": "A New Calendar" 6 | } -------------------------------------------------------------------------------- /test/mocks/500.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "global", 6 | "reason": "backendError", 7 | "message": "Backend Error", 8 | } 9 | ], 10 | "code": 500, 11 | "message": "Backend Error" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/403_forbidden.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "reason": "ForbiddenError", 7 | "message": "Forbidden" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "Forbidden" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/403_unknown.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "reason": "SomeRandomError", 7 | "message": "RandomError" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "RandomError" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/404.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "code": 404, 4 | "message": "Not Found", 5 | "errors": [ 6 | { 7 | "domain": "global", 8 | "message": "Not Found", 9 | "reason": "notFound" 10 | } 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/410.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "global", 6 | "reason": "deleted", 7 | "message": "Resource has been deleted" 8 | } 9 | ], 10 | "code": 410, 11 | "message": "Resource has been deleted" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/successful_login.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "ya29.hYjPO0uHt63uWr5qmQtMEReZEvILcdGlPCOHDy6quKPyEQaQQvqaVAlLAVASaRm_O0a7vkZ91T8xyQ", 3 | "token_type": "Bearer", 4 | "expires_in": 3600, 5 | "refresh_token": "1/aJUy7pQzc4fUMX89BMMLeAfKcYteBKRMpQvf4fQFX0" 6 | } -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # guard 'minitest' do 2 | # # with Minitest::Unit 3 | # watch(%r|^test/(.*)\/?test_(.*)\.rb|) 4 | # watch(%r|^lib/(.*)([^/]+)\.rb|) { 'test' } 5 | # watch(%r|^test/mocks(.*)([^/]+)\.json|) { 'test' } 6 | # watch(%r|^test/helper\.rb|) { 'test' } 7 | # end 8 | -------------------------------------------------------------------------------- /test/mocks/update_calendar.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#calendar", 3 | "etag": "\"W8S50vTLOjlc76YTghtSZVQwSEE/cpIguMlgGWrAb4L_lleRpa2ALCA\"", 4 | "id": "gqeb0i6v737kfu5md0f35htjlg@group.calendar.google.com", 5 | "summary": "Our Company", 6 | "description": "Work event list" 7 | } -------------------------------------------------------------------------------- /test/mocks/403_daily_limit.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "reason": "dailyLimitExceeded", 7 | "message": "Daily Limit Exceeded" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "Daily Limit Exceeded" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/403_rate_limit.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "reason": "rateLimitExceeded", 7 | "message": "Rate Limit Exceeded" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "Rate Limit Exceeded" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/login_with_auth_code_success.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "ya29.hYjPO0uHt63uWr5qmQtMEReZEvILcdGlPCOHDy6quKPyEQaQQvqaVAlLAVASaRm_O0a7vkZ91T8xyQ", 3 | "token_type": "Bearer", 4 | "expires_in": 3600, 5 | "refresh_token": "1/aJUy7pQzc4fUMX89BMMLeAfKcYteBKRMpQvf4fQFX0" 6 | } -------------------------------------------------------------------------------- /lib/google_calendar.rb: -------------------------------------------------------------------------------- 1 | module Google 2 | require 'google/errors' 3 | require 'google/calendar' 4 | require 'google/calendar_list' 5 | require 'google/calendar_list_entry' 6 | require 'google/connection' 7 | require 'google/event' 8 | require 'google/freebusy' 9 | end 10 | -------------------------------------------------------------------------------- /test/mocks/403_user_rate_limit.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "reason": "userRateLimitExceeded", 7 | "message": "User Rate Limit Exceeded" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "User Rate Limit Exceeded" 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/409.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "global", 6 | "reason": "duplicate", 7 | "message": "The requested identifier already exists." 8 | } 9 | ], 10 | "code": 409, 11 | "message": "The requested identifier already exists." 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/403_calendar_rate_limit.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "usageLimits", 6 | "message": "Calendar usage limits exceeded.", 7 | "reason": "quotaExceeded" 8 | } 9 | ], 10 | "code": 403, 11 | "message": "Calendar usage limits exceeded." 12 | } 13 | } -------------------------------------------------------------------------------- /test/mocks/401.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "global", 6 | "reason": "authError", 7 | "message": "Invalid Credentials", 8 | "locationType": "header", 9 | "location": "Authorization", 10 | } 11 | ], 12 | "code": 401, 13 | "message": "Invalid Credentials" 14 | } 15 | } -------------------------------------------------------------------------------- /test/mocks/412.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "global", 6 | "reason": "conditionNotMet", 7 | "message": "Precondition Failed", 8 | "locationType": "header", 9 | "location": "If-Match", 10 | } 11 | ], 12 | "code": 412, 13 | "message": "Precondition Failed" 14 | } 15 | } -------------------------------------------------------------------------------- /test/mocks/get_calendar.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#calendar", 3 | "etag": "\"W8S50vTLOjlc76YTghtSZVQwSEE/cpIguMlgGWrAb4L_lleRpa2ALCA\"", 4 | "id": "gqeb0i6v737kfu5md0f35htjlg@group.calendar.google.com", 5 | "summary": "Some Calendar", 6 | "description": "Our work events", 7 | "location": "Portland", 8 | "timeZone": "America/Los_Angeles" 9 | } -------------------------------------------------------------------------------- /test/mocks/cancelled_events.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#event", 3 | "etag": "\"2864417954210000\"", 4 | "id": "5hskl88u70fdekmted80rkjut4_20150701T010000Z", 5 | "status": "cancelled", 6 | "recurringEventId": "5hskl88u70fdekmted80rkjut4_R20150527T010000", 7 | "originalStartTime": {"dateTime": "2015-07-01T10:00:00+09:00"} 8 | } 9 | 10 | -------------------------------------------------------------------------------- /test/mocks/400.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": { 3 | "errors": [ 4 | { 5 | "domain": "calendar", 6 | "reason": "timeRangeEmpty", 7 | "message": "The specified time range is empty.", 8 | "locationType": "parameter", 9 | "location": "timeMax", 10 | } 11 | ], 12 | "code": 400, 13 | "message": "The specified time range is empty." 14 | } 15 | } -------------------------------------------------------------------------------- /test/mocks/403.json: -------------------------------------------------------------------------------- 1 | error: { 2 | errors: [ 3 | { 4 | "domain": "global", 5 | "reason": "appNotInstalled", 6 | "message": "The authenticated user has not installed the app with client id clientId" 7 | } 8 | ], 9 | "code": 403, 10 | "message": "The authenticated user has not installed the app with client id clientId" 11 | } 12 | } -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | CLIENT_ID='671053090364-ntifn8rauvhib9h3vnsegi6dhfglk9ue.apps.googleusercontent.com' 2 | CLIENT_SECRET='roBgdbfEmJwPgrgi2mRbbO-f' 3 | REFRESH_TOKEN='1/eiqBWx8aj-BsdhwvlzDMFOUN1IN_HyThvYTujyksO4c' 4 | CALENDAR_ID='klei8jnelo09nflqehnvfzipgs@group.calendar.google.com' 5 | ACCESS_TOKEN='ya29.hYjPO0uHt63uWr5qmQtMEReZEvILcdGlPCOHDy6quKPyEQaQQvqaVAlLAVASaRm_O0a7vkZ91T8xyQ' 6 | REDIRECT_URL='https://mytesturl.com/' 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - 2.4 5 | - 2.5 6 | - 2.6 7 | - 2.7 8 | addons: 9 | code_climate: 10 | repo_token: 35bb9d218078742d37436a9c9a8b78199e8c7312d034081881de670e95b8dcad 11 | after_success: 12 | - bundle exec codeclimate-test-reporter 13 | # uncomment this line if your project needs to run something other than `rake`: 14 | # script: bundle exec rspec spec 15 | -------------------------------------------------------------------------------- /test/mocks/empty_events.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#events", 3 | "defaultReminders": [], 4 | "description": "My Personal Events Calendar", 5 | "items": [], 6 | "updated": "2014-11-15T22:32:46.965Z", 7 | "summary": "My Events Calendar", 8 | "etag": "\"1234567890123000\"", 9 | "nextSyncToken": "AKi8uge987ECEIjyiZnV_cECGAU=", 10 | "timeZone": "America/Los_Angeles", 11 | "accessRole": "owner" 12 | } -------------------------------------------------------------------------------- /test/mocks/freebusy_query.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeMin": "2015-03-06T00:00:00.000Z", 3 | "timeMax": "2015-03-06T23:59:59.000Z", 4 | "calendars": { 5 | "busy-calendar-id": { 6 | "busy": [ 7 | { 8 | "start": "2015-03-06T10:00:00Z", 9 | "end": "2015-03-06T11:00:00Z" 10 | }, 11 | { 12 | "start": "2015-03-06T11:30:00Z", 13 | "end": "2015-03-06T11:30:00Z" 14 | } 15 | ] 16 | }, 17 | "not-busy-calendar-id": { 18 | "busy": [ 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | require 'rubygems' 5 | require 'bundler' 6 | begin 7 | Bundler.setup(:default, :development) 8 | rescue Bundler::BundlerError => e 9 | $stderr.puts e.message 10 | $stderr.puts "Run `bundle install` to install missing gems" 11 | exit e.status_code 12 | end 13 | 14 | require "minitest/autorun" 15 | require 'minitest/reporters' 16 | require 'shoulda/context' 17 | require 'mocha/setup' 18 | require 'faraday' 19 | 20 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 21 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 22 | require 'google_calendar' 23 | 24 | Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new 25 | 26 | class Minitest::Test 27 | @@mock_path = File.expand_path(File.join(File.dirname(__FILE__), 'mocks')) 28 | end 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | # rdoc generated 4 | rdoc 5 | # yard generated 6 | doc 7 | .yardoc 8 | # bundler 9 | .bundle 10 | # jeweler generated 11 | pkg 12 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 13 | # 14 | # * Create a file at ~/.gitignore 15 | # * Include files you want ignored 16 | # * Run: git config --global core.excludesfile ~/.gitignore 17 | # 18 | # After doing this, these files will be ignored in all your git projects, 19 | # saving you from having to 'pollute' every project you touch with them 20 | # 21 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 22 | # 23 | # For MacOS: 24 | # 25 | .DS_Store 26 | # For TextMate 27 | *.tmproj 28 | tmtags 29 | # For emacs: 30 | #*~ 31 | #\#* 32 | #.\#* 33 | # 34 | # For vim: 35 | #*.swp 36 | notes.md 37 | test_harness.rb 38 | *.gem 39 | # dotenv 40 | .env 41 | -------------------------------------------------------------------------------- /test/mocks/create_quickadd_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "confirmed", 3 | "kind": "calendar#event", 4 | "end": { 5 | "dateTime": "2014-11-16T22:21:24-08:00" 6 | }, 7 | "created": "2014-11-17T05:21:24.000Z", 8 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 9 | "reminders": { 10 | "useDefault": true 11 | }, 12 | "htmlLink": "https://www.google.com/calendar/event?eid=cHBtNzMybzBqOWpuZDI0M25zZWx1ZjllYjQgZ3FlYjBpNnY3MzdrZnU1bWQwZjNldWtqbGdAZw", 13 | "sequence": 0, 14 | "updated": "2014-11-17T05:21:24.614Z", 15 | "summary": "Test Event", 16 | "start": { 17 | "dateTime": "2014-11-16T21:21:24-08:00" 18 | }, 19 | "etag": "\"2832403369228000\"", 20 | "organizer": { 21 | "self": true, 22 | "displayName": "Some Person", 23 | "email": "klei8jnelo09nflqehnvfzipgs@group.calendar.google.com" 24 | }, 25 | "creator": { 26 | "displayName": "Some Person", 27 | "email": "some.person@gmail.com" 28 | }, 29 | "id": "fhru34kt6ikmr20knd2456l08n" 30 | } -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | require 'rake' 11 | 12 | require 'rake/testtask' 13 | Rake::TestTask.new(:test) do |test| 14 | test.libs << 'lib' << 'test' 15 | test.pattern = 'test/**/test_*.rb' 16 | test.verbose = true 17 | end 18 | 19 | task :default => :test 20 | 21 | require 'rdoc/task' 22 | Rake::RDocTask.new do |rdoc| 23 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 24 | 25 | rdoc.rdoc_dir = 'rdoc' 26 | rdoc.title = "google_calendar #{version}" 27 | rdoc.rdoc_files.include('README*') 28 | rdoc.rdoc_files.include('lib/**/*.rb') 29 | end 30 | 31 | desc "Load environment settings from .env" 32 | task :dotenv do 33 | require "dotenv" 34 | FileUtils.copy('.env.test', '.env') unless File.exist?('.env') 35 | Dotenv.load 36 | end 37 | 38 | task :environment => :dotenv 39 | task :test => :dotenv 40 | -------------------------------------------------------------------------------- /test/mocks/create_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "confirmed", 3 | "kind": "calendar#event", 4 | "end": { 5 | "dateTime": "2014-11-16T21:17:31-08:00" 6 | }, 7 | "description": "A New Event", 8 | "created": "2014-11-17T03:19:37.000Z", 9 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 10 | "reminders": { 11 | "useDefault": true 12 | }, 13 | "htmlLink": "https://www.google.com/calendar/event?eid=aslkjhdfasod89f7aspdfghsdlkfghsdlkjghsdfgposdr7gpsdorigh", 14 | "sequence": 0, 15 | "updated": "2014-11-17T03:19:37.944Z", 16 | "summary": "New Event", 17 | "start": { 18 | "dateTime": "2014-11-16T20:17:31-08:00" 19 | }, 20 | "etag": "\"123456728900000\"", 21 | "organizer": { 22 | "self": true, 23 | "displayName": "Some Person", 24 | "email": "klei8jnelo09nflqehnvfzipgs@group.calendar.google.com" 25 | }, 26 | "creator": { 27 | "displayName": "Some Person", 28 | "email": "some.person@gmail.com" 29 | }, 30 | "id": "fhru34kt6ikmr20knd2456l08n", 31 | "extendedProperties": { 32 | "shared": { 33 | "key": "value" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /test/mocks/find_event_by_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "confirmed", 3 | "kind": "calendar#event", 4 | "end": { 5 | "dateTime": "2008-09-24T11:00:00-07:00" 6 | }, 7 | "description": "Test Event", 8 | "created": "2008-09-18T21:17:11.000Z", 9 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 10 | "reminders": { 11 | "useDefault": true 12 | }, 13 | "htmlLink": "https://www.google.com/calendar/event?eid=hJo0SgXn9qMwOHJjYWJpNnBhNTQ5dHRqbGsgZ3FlYjBpNnY3MzdrZnU1bWQwZjNldWtqbGdAZw", 14 | "sequence": 1, 15 | "updated": "2008-10-24T23:08:09.010Z", 16 | "summary": "This is a test event", 17 | "start": { 18 | "dateTime": "2008-09-24T10:30:00-07:00" 19 | }, 20 | "etag": "\"2449779378020000\"", 21 | "organizer": { 22 | "self": true, 23 | "displayName": "Some Person", 24 | "email": "some.person@gmail.com" 25 | }, 26 | "creator": { 27 | "displayName": "Some Person", 28 | "email": "some.person@gmail.com" 29 | }, 30 | "id": "fhru34kt6ikmr20knd2456l08n", 31 | "extendedProperties": { 32 | "shared": { 33 | "key": "value" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /test/mocks/missing_date.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "confirmed", 3 | "kind": "calendar#event", 4 | "end": { 5 | "dateTimes": "2008-09-24T11:00:00-07:00" 6 | }, 7 | "description": "Test Event", 8 | "created": "2008-09-18T21:17:11.000Z", 9 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 10 | "reminders": { 11 | "useDefault": true 12 | }, 13 | "htmlLink": "https://www.google.com/calendar/event?eid=hJo0SgXn9qMwOHJjYWJpNnBhNTQ5dHRqbGsgZ3FlYjBpNnY3MzdrZnU1bWQwZjNldWtqbGdAZw", 14 | "sequence": 1, 15 | "updated": "2008-10-24T23:08:09.010Z", 16 | "summary": "This is a test event", 17 | "start": { 18 | "dateTimes": "2008-09-24T10:30:00-07:00" 19 | }, 20 | "etag": "\"2449779378020000\"", 21 | "organizer": { 22 | "self": true, 23 | "displayName": "Some Person", 24 | "email": "some.person@gmail.com" 25 | }, 26 | "creator": { 27 | "displayName": "Some Person", 28 | "email": "some.person@gmail.com" 29 | }, 30 | "id": "fhru34kt6ikmr20knd2456l12n", 31 | "extendedProperties": { 32 | "shared": { 33 | "key": "value" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /test/mocks/find__all_day_event_by_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "confirmed", 3 | "kind": "calendar#event", 4 | "end": { 5 | "date": "2008-09-24T11:00:00-07:00" 6 | }, 7 | "description": "Test Event", 8 | "created": "2008-09-18T21:17:11.000Z", 9 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 10 | "reminders": { 11 | "useDefault": true 12 | }, 13 | "htmlLink": "https://www.google.com/calendar/event?eid=hJo0SgXn9qMwOHJjYWJpNnBhNTQ5dHRqbGsgZ3FlYjBpNnY3MzdrZnU1bWQwZjNldWtqbGdAZw", 14 | "sequence": 1, 15 | "updated": "2008-10-24T23:08:09.010Z", 16 | "summary": "This is a test event", 17 | "start": { 18 | "date": "2008-09-24T10:30:00-07:00" 19 | }, 20 | "etag": "\"2449779378020000\"", 21 | "organizer": { 22 | "self": true, 23 | "displayName": "Some Person", 24 | "email": "some.person@gmail.com" 25 | }, 26 | "creator": { 27 | "displayName": "Some Person", 28 | "email": "some.person@gmail.com" 29 | }, 30 | "id": "fhru34kt6ikmr20knd2456l10n", 31 | "extendedProperties": { 32 | "shared": { 33 | "key": "value" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Northworld, LLC. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/google/calendar_list_entry.rb: -------------------------------------------------------------------------------- 1 | module Google 2 | 3 | # 4 | # Represents a Google Calendar List Entry 5 | # 6 | # See https://developers.google.com/google-apps/calendar/v3/reference/calendarList#resource 7 | # 8 | # === Attributes 9 | # 10 | # * +id+ - The Google assigned id of the calendar. Read only. 11 | # * +summary+ - Title of the calendar. Read-only. 12 | # * +time_zone+ - The time zone of the calendar. Optional. Read-only. 13 | # * +access_role+ - The effective access role that the authenticated user has on the calendar. Read-only. 14 | # * +primary?+ - Whether the calendar is the primary calendar of the authenticated user. Read-only. 15 | # 16 | class CalendarListEntry 17 | attr_reader :id, :summary, :time_zone, :access_role, :primary, :connection 18 | alias_method :primary?, :primary 19 | 20 | def initialize(params, connection) 21 | @id = params['id'] 22 | @summary = params['summary'] 23 | @time_zone = params['timeZone'] 24 | @access_role = params['accessRole'] 25 | @primary = params.fetch('primary', false) 26 | @connection = connection 27 | end 28 | 29 | def to_calendar 30 | Calendar.new({:calendar => @id}, @connection) 31 | end 32 | 33 | def self.build_from_google_feed(response, connection) 34 | items = response['items'] 35 | items.collect { |item| CalendarListEntry.new(item, connection) } 36 | end 37 | 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /test/mocks/query_events.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#events", 3 | "defaultReminders": [], 4 | "description": "My Personal Events Calendar", 5 | "items": [ 6 | { 7 | "status": "confirmed", 8 | "kind": "calendar#event", 9 | "end": { 10 | "dateTime": "2008-09-24T11:00:00-07:00" 11 | }, 12 | "description": "My Test Event", 13 | "created": "2008-09-18T21:17:11.000Z", 14 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 15 | "reminders": { 16 | "useDefault": true 17 | }, 18 | "htmlLink": "https://www.google.com/calendar/event?eid=dDAwam5wcWMwOHJjYWJpNnBhNTQ5dHRqbGsgZ3FlYjBpNnY3MzdrZnU1bWQwZjNldWtqbGdAZw", 19 | "sequence": 1, 20 | "updated": "2008-10-24T23:08:09.010Z", 21 | "summary": "Test Event", 22 | "colorId": "3", 23 | "start": { 24 | "dateTime": "2008-09-24T10:30:00-07:00" 25 | }, 26 | "etag": "\"2449779378020000\"", 27 | "organizer": { 28 | "self": true, 29 | "displayName": "Some Person", 30 | "email": "some.person@gmail.com" 31 | }, 32 | "creator": { 33 | "displayName": "Some Person", 34 | "email": "some.person@gmail.com" 35 | }, 36 | "id": "fhru34kt6ikmr20knd2456l08n", 37 | "extendedProperties": { 38 | "shared": { 39 | "key": "value" 40 | } 41 | } 42 | } 43 | ], 44 | "updated": "2014-11-15T22:32:46.965Z", 45 | "summary": "My Events Calendar", 46 | "etag": "\"1234567890123000\"", 47 | "nextSyncToken": "AKi8uge987ECEIjyiZnV_cECGAU=", 48 | "timeZone": "America/Los_Angeles", 49 | "accessRole": "owner" 50 | } -------------------------------------------------------------------------------- /lib/google/calendar_list.rb: -------------------------------------------------------------------------------- 1 | module Google 2 | 3 | # 4 | # CalendarList is the main object you use to find Calendars. 5 | # 6 | class CalendarList 7 | 8 | attr_reader :connection 9 | 10 | # 11 | # Setup and connect to the user's list of Google Calendars. 12 | # 13 | # The +params+ parameter accepts 14 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/). REQUIRED 15 | # * :client_secret => the client secret you received from Google after registering your application with them. REQUIRED 16 | # * :redirect_url => the url where your users will be redirected to after they have successfully permitted access to their calendars. REQUIRED 17 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 18 | # 19 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 20 | # 21 | def initialize(params={}, connection=nil) 22 | @connection = connection || Connection.factory(params) 23 | end 24 | 25 | # 26 | # Find all entries on the user's calendar list. Returns an array of CalendarListEntry objects. 27 | # 28 | def fetch_entries 29 | response = @connection.send("/users/me/calendarList", :get) 30 | 31 | return nil if response.status != 200 || response.body.empty? 32 | 33 | CalendarListEntry.build_from_google_feed(JSON.parse(response.body), @connection) 34 | end 35 | 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /google_calendar.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "google_calendar" 5 | s.version = "0.6.4" 6 | s.date = "2018-03-23" 7 | 8 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 9 | 10 | s.authors = ["Steve Zich"] 11 | s.email = "steve.zich@gmail.com" 12 | 13 | s.summary = "A lightweight Google Calendar API wrapper" 14 | s.description = "A minimal wrapper around the google calendar API" 15 | s.homepage = "http://northworld.github.io/google_calendar/" 16 | s.licenses = ["MIT"] 17 | 18 | s.extra_rdoc_files = [ 19 | "LICENSE.txt", 20 | "README.rdoc" 21 | ] 22 | 23 | s.files = `git ls-files`.split("\n") 24 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 25 | 26 | s.require_paths = ["lib"] 27 | s.rubygems_version = "2.7.6" 28 | 29 | s.add_runtime_dependency(%q, ["> 2.7.0"]) 30 | s.add_runtime_dependency(%q, ["~> 0.7"]) 31 | s.add_runtime_dependency(%q, [">= 1.8.3"]) 32 | s.add_runtime_dependency(%q, ">= 0.3", "< 1.1") 33 | 34 | s.add_development_dependency(%q, ["~> 1.6"]) 35 | s.add_development_dependency(%q, ["~> 0.9"]) 36 | s.add_development_dependency(%q, ["~> 5.1"]) 37 | s.add_development_dependency(%q, ["~> 1.2"]) 38 | s.add_development_dependency(%q, "~> 2.0") 39 | s.add_development_dependency(%q, [">= 1.2"]) 40 | s.add_development_dependency(%q, ["~> 1.4"]) 41 | s.add_development_dependency(%q, [">= 11"]) 42 | s.add_development_dependency(%q, ["> 6.3.1"]) 43 | s.add_development_dependency(%q, ["~> 2.1"]) 44 | 45 | end 46 | -------------------------------------------------------------------------------- /test/mocks/repeating_events.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#events", 3 | "defaultReminders": [], 4 | "description": "My Personal Events Calendar", 5 | "items": [ 6 | { 7 | "status": "confirmed", 8 | "kind": "calendar#event", 9 | "end": { 10 | "timeZone": "America/Los_Angeles", 11 | "dateTime": "2014-11-15T18:00:00-08:00" 12 | }, 13 | "created": "2014-11-17T00:45:30.000Z", 14 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 15 | "reminders": { 16 | "useDefault": true 17 | }, 18 | "htmlLink": "https://www.google.com/calendar/event?eid=MjRmNHZhY2pzY3A1YjB0NHE2OThoMGs2YXNfMjAxNDExMTZUMDEwMDAwWiBncWViMGk2djczN2tmdTVtZDBmM2V1a2psZ0Bn", 19 | "sequence": 0, 20 | "updated": "2014-11-17T00:45:30.431Z", 21 | "summary": "Test Repeating Events", 22 | "recurrence": [ 23 | "RRULE:FREQ=DAILY;COUNT=3" 24 | ], 25 | "start": { 26 | "timeZone": "America/Los_Angeles", 27 | "dateTime": "2014-11-15T17:00:00-08:00" 28 | }, 29 | "etag": "\"2832370260862000\"", 30 | "organizer": { 31 | "self": true, 32 | "displayName": "Some Person", 33 | "email": "some.person@gmail.com" 34 | }, 35 | "creator": { 36 | "displayName": "Some Person", 37 | "email": "some.person@gmail.com" 38 | }, 39 | "id": "fhru34kt6ikmr20knd2456l08n", 40 | "extendedProperties": { 41 | "shared": { 42 | "key": "value" 43 | } 44 | } 45 | } 46 | ], 47 | "updated": "2014-11-17T00:45:30.522Z", 48 | "summary": "My Events Calendar", 49 | "etag": "\"1416185130522000\"", 50 | "nextSyncToken": "AKi8uge987ECEIjyiZnV_cECGAU=", 51 | "timeZone": "America/Los_Angeles", 52 | "accessRole": "owner" 53 | } -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | google_calendar (0.6.4) 5 | TimezoneParser (>= 0.3, < 1.1) 6 | addressable (> 2.7.0) 7 | json (>= 1.8.3) 8 | signet (~> 0.7) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | TimezoneParser (1.0.0) 14 | insensitive_hash 15 | sqlite3 16 | tzinfo 17 | addressable (2.8.0) 18 | public_suffix (>= 2.0.2, < 5.0) 19 | ansi (1.5.0) 20 | builder (3.2.4) 21 | codeclimate-test-reporter (1.0.9) 22 | simplecov (<= 0.13) 23 | concurrent-ruby (1.1.6) 24 | docile (1.1.5) 25 | dotenv (2.7.6) 26 | faraday (1.0.1) 27 | multipart-post (>= 1.2, < 3) 28 | insensitive_hash (0.3.3) 29 | json (2.3.1) 30 | jwt (2.2.1) 31 | minitest (5.14.2) 32 | minitest-reporters (1.4.2) 33 | ansi 34 | builder 35 | minitest (>= 5.0) 36 | ruby-progressbar 37 | mocha (1.11.2) 38 | multi_json (1.14.1) 39 | multipart-post (2.1.1) 40 | psych (4.0.3) 41 | stringio 42 | public_suffix (4.0.6) 43 | rake (13.0.1) 44 | rb-fsevent (0.10.4) 45 | rdoc (6.4.0) 46 | psych (>= 4.0.0) 47 | ruby-progressbar (1.10.1) 48 | shoulda-context (2.0.0) 49 | signet (0.14.0) 50 | addressable (~> 2.3) 51 | faraday (>= 0.17.3, < 2.0) 52 | jwt (>= 1.5, < 3.0) 53 | multi_json (~> 1.10) 54 | simplecov (0.13.0) 55 | docile (~> 1.1.0) 56 | json (>= 1.8, < 3) 57 | simplecov-html (~> 0.10.0) 58 | simplecov-html (0.10.2) 59 | sqlite3 (1.4.2) 60 | stringio (3.0.1) 61 | terminal-notifier-guard (1.7.0) 62 | tzinfo (2.0.2) 63 | concurrent-ruby (~> 1.0) 64 | 65 | PLATFORMS 66 | ruby 67 | 68 | DEPENDENCIES 69 | bundler (>= 1.2) 70 | codeclimate-test-reporter 71 | dotenv (~> 2.1) 72 | google_calendar! 73 | minitest (~> 5.1) 74 | minitest-reporters (~> 1.2) 75 | mocha (~> 1.4) 76 | rake (>= 11) 77 | rb-fsevent (~> 0.9) 78 | rdoc (> 6.3.1) 79 | shoulda-context (~> 2.0) 80 | terminal-notifier-guard (~> 1.6) 81 | 82 | BUNDLED WITH 83 | 2.2.16 84 | -------------------------------------------------------------------------------- /test/mocks/find_calendar_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "calendar#calendarList", 3 | "etag": "\"1420633154361000\"", 4 | "nextSyncToken": "00001420633154361000", 5 | "items": [ 6 | { 7 | "kind": "calendar#calendarListEntry", 8 | "etag": "\"1420564622795000\"", 9 | "id": "initech.com_ed493d0a9b46ea46c3a0d48611ce@resource.calendar.google.com", 10 | "summary": "Small cubicle", 11 | "timeZone": "America/Los_Angeles", 12 | "colorId": "2", 13 | "backgroundColor": "#d06b64", 14 | "foregroundColor": "#000000", 15 | "accessRole": "owner", 16 | "defaultReminders": [] 17 | }, 18 | { 19 | "kind": "calendar#calendarListEntry", 20 | "etag": "\"1420564132697000\"", 21 | "id": "initech.com_db18a4e59c230a5cc5d2b069a30f@resource.calendar.google.com", 22 | "summary": "Large cubicle", 23 | "timeZone": "America/Los_Angeles", 24 | "colorId": "3", 25 | "backgroundColor": "#f83a22", 26 | "foregroundColor": "#000000", 27 | "accessRole": "reader", 28 | "defaultReminders": [] 29 | }, 30 | { 31 | "kind": "calendar#calendarListEntry", 32 | "etag": "\"1420564622222000\"", 33 | "id": "bob@initech.com", 34 | "summary": "Bob's Calendar", 35 | "timeZone": "Europe/London", 36 | "colorId": "17", 37 | "backgroundColor": "#9a9cff", 38 | "foregroundColor": "#000000", 39 | "accessRole": "owner", 40 | "defaultReminders": [ 41 | { 42 | "method": "popup", 43 | "minutes": 10 44 | } 45 | ], 46 | "notificationSettings": { 47 | "notifications": [ 48 | { 49 | "type": "eventCreation", 50 | "method": "email" 51 | }, 52 | { 53 | "type": "eventChange", 54 | "method": "email" 55 | }, 56 | { 57 | "type": "eventCancellation", 58 | "method": "email" 59 | }, 60 | { 61 | "type": "eventResponse", 62 | "method": "email" 63 | } 64 | ] 65 | }, 66 | "primary": true 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /readme_code.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Uncomment the LOAD_PATH lines if you want to run against the 3 | # local version of the gem. 4 | # 5 | # $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'lib')) 6 | # $LOAD_PATH.unshift(File.dirname(__FILE__)) 7 | 8 | require 'rubygems' 9 | require 'google_calendar' 10 | 11 | # Create an instance of the calendar. 12 | cal = Google::Calendar.new(:client_id => YOUR_CLIENT_ID, 13 | :client_secret => YOUR_SECRET, 14 | :calendar => YOUR_CALENDAR_ID, 15 | :redirect_url => YOUR_REDIRECT_URL # this is what Google uses for 'applications' 16 | ) 17 | 18 | puts "Do you already have a refresh token? (y/n)" 19 | has_token = $stdin.gets.chomp 20 | 21 | if has_token.downcase != 'y' 22 | 23 | # A user needs to approve access in order to work with their calendars. 24 | puts "Visit the following web page in your browser and approve access." 25 | puts cal.authorize_url 26 | puts "\nCopy the code out of the paramters after Google redirects you to your provided redirect_url" 27 | 28 | # Pass the ONE TIME USE access code here to login and get a refresh token that you can use for access from now on. 29 | refresh_token = cal.login_with_auth_code( $stdin.gets.chomp ) 30 | 31 | puts "\nMake sure you SAVE YOUR REFRESH TOKEN so you don't have to prompt the user to approve access again." 32 | puts "your refresh token is:\n\t#{refresh_token}\n" 33 | puts "Press return to continue" 34 | $stdin.gets.chomp 35 | 36 | else 37 | 38 | puts "Enter your refresh token" 39 | refresh_token = $stdin.gets.chomp 40 | cal.login_with_refresh_token(refresh_token) 41 | 42 | # Note: You can also pass your refresh_token to the constructor and it will login at that time. 43 | 44 | end 45 | 46 | event = cal.create_event do |e| 47 | e.title = 'A Cool Event' 48 | e.start_time = Time.now 49 | e.end_time = Time.now + (60 * 60) # seconds * min 50 | e.extended_properties = { 51 | 'private' => { 52 | 'private_prop' => 'private_val' 53 | }, 54 | 'shared' => { 55 | 'shared_prop' => 'shared_val' 56 | } 57 | } 58 | end 59 | 60 | puts event 61 | 62 | event = cal.find_or_create_event_by_id(event.id) do |e| 63 | e.title = 'An Updated Cool Event' 64 | e.end_time = Time.now + (60 * 60 * 2) # seconds * min * hours 65 | e.color_id = 3 # google allows colors 0-11 66 | end 67 | 68 | puts event 69 | 70 | # Find through the custom property 71 | event = cal.find_events_by_extended_properties({'private' => { 'private_prop' => 'private_val'}}) 72 | 73 | puts event 74 | 75 | # All events 76 | puts cal.events 77 | 78 | # Query events 79 | puts cal.find_events('some query here') 80 | -------------------------------------------------------------------------------- /lib/google/errors.rb: -------------------------------------------------------------------------------- 1 | module Google 2 | # Signet::AuthorizationError 3 | # Not part of Google Calendar API Errors 4 | class HTTPAuthorizationFailed < StandardError; end 5 | 6 | # Google Calendar API Errors per documentation 7 | # https://developers.google.com/google-apps/calendar/v3/errors 8 | 9 | # 400: Bad Request 10 | # 11 | # User error. This can mean that a required field or parameter has not been 12 | # provided, the value supplied is invalid, or the combination of provided 13 | # fields is invalid. 14 | class HTTPRequestFailed < StandardError; end 15 | 16 | # 401: Invalid Credentials 17 | # 18 | # Invalid authorization header. The access token you're using is either 19 | # expired or invalid. 20 | class InvalidCredentialsError < StandardError; end 21 | 22 | # 403: Daily Limit Exceeded 23 | # 24 | # The Courtesy API limit for your project has been reached. 25 | class DailyLimitExceededError < StandardError; end 26 | 27 | # 403: User Rate Limit Exceeded 28 | # 29 | # The per-user limit from the Developer Console has been reached. 30 | class UserRateLimitExceededError < StandardError; end 31 | 32 | # 403: Rate Limit Exceeded 33 | # 34 | # The user has reached Google Calendar API's maximum request rate per 35 | # calendar or per authenticated user. 36 | class RateLimitExceededError < StandardError; end 37 | 38 | # 403: Calendar usage limits exceeded 39 | # 40 | # The user reached one of the Google Calendar limits in place to protect 41 | # Google users and infrastructure from abusive behavior. 42 | class CalendarUsageLimitExceededError < StandardError; end 43 | 44 | # 404: Not Found 45 | # 46 | # The specified resource was not found. 47 | class HTTPNotFound < StandardError; end 48 | 49 | # 409: The requested identifier already exists 50 | # 51 | # An instance with the given ID already exists in the storage. 52 | class RequestedIdentifierAlreadyExistsError < StandardError; end 53 | 54 | # 410: Gone 55 | # 56 | # SyncToken or updatedMin parameters are no longer valid. This error can also 57 | # occur if a request attempts to delete an event that has already been 58 | # deleted. 59 | class GoneError < StandardError; end 60 | 61 | # 412: Precondition Failed 62 | # 63 | # The etag supplied in the If-match header no longer corresponds to the 64 | # current etag of the resource. 65 | class PreconditionFailedError < StandardError; end 66 | 67 | # 500: Backend Error 68 | # 69 | # An unexpected error occurred while processing the request. 70 | class BackendError < StandardError; end 71 | 72 | # 73 | # 403: Forbidden Error 74 | # 75 | # User has no authority to conduct the requested operation on the resource. 76 | # This is not a part of official Google Calendar API Errors documentation. 77 | class ForbiddenError < StandardError; end 78 | end 79 | -------------------------------------------------------------------------------- /lib/google/freebusy.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'json' 3 | 4 | module Google 5 | 6 | # 7 | # Freebusy returns free/busy information for a set of calendars 8 | # 9 | class Freebusy 10 | 11 | attr_reader :connection 12 | 13 | # 14 | # Setup and query the free/busy status of a collection of calendars. 15 | # 16 | # The +params+ parameter accepts 17 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/). REQUIRED 18 | # * :client_secret => the client secret you received from Google after registering your application with them. REQUIRED 19 | # * :redirect_url => the url where your users will be redirected to after they have successfully permitted access to their calendars. REQUIRED 20 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 21 | # 22 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 23 | # 24 | def initialize(params={}, connection=nil) 25 | @connection = connection || Connection.factory(params) 26 | end 27 | 28 | # 29 | # Find the busy times of the supplied calendar IDs, within the boundaries 30 | # of the supplied start_time and end_time 31 | # 32 | # The arguments supplied are 33 | # * calendar_ids => array of Google calendar IDs as strings 34 | # * start_time => a Time object, the start of the interval for the query. 35 | # * end_time => a Time object, the end of the interval for the query. 36 | # 37 | def query(calendar_ids, start_time, end_time) 38 | query_content = json_for_query(calendar_ids, start_time, end_time) 39 | response = @connection.send("/freeBusy", :post, query_content) 40 | 41 | return nil if response.status != 200 || response.body.empty? 42 | 43 | parse_freebusy_response(response.body) 44 | end 45 | 46 | private 47 | 48 | # 49 | # Prepare the JSON 50 | # 51 | def json_for_query(calendar_ids, start_time, end_time) 52 | {}.tap{ |obj| 53 | obj[:items] = calendar_ids.map {|id| Hash[:id, id] } 54 | obj[:timeMin] = start_time.utc.iso8601 55 | obj[:timeMax] = end_time.utc.iso8601 56 | }.to_json 57 | end 58 | 59 | def parse_freebusy_response(response_body) 60 | query_result = JSON.parse(response_body) 61 | 62 | return nil unless query_result['calendars'].is_a? Hash 63 | 64 | query_result['calendars'].each_with_object({}) do |(calendar_id, value), result| 65 | value['busy'].each { |date_times| date_times.transform_values! { |date_time| DateTime.parse(date_time) } } 66 | result[calendar_id] = value['busy'] || [] 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/mocks/events.json: -------------------------------------------------------------------------------- 1 | { 2 | "nextPageToken": "CkkKO183NTM0Mmdobzc0cDNhYmE0ODUyajZiOWs2bDBqMmJhMTZsMjNjYmExOHAyajhoOXA3NG80YWRoaDZrGAEggICA7NHLrqgTGg0IABIAGIjyiZnV_cEC", 3 | "kind": "calendar#events", 4 | "defaultReminders": [], 5 | "description": "My Personal Events Calenda", 6 | "items": [ 7 | { 8 | "status": "confirmed", 9 | "kind": "calendar#event", 10 | "end": { 11 | "timeZone": "America/Los_Angeles", 12 | "dateTime": "2011-10-03T10:30:00-07:00" 13 | }, 14 | "created": "2011-04-04T23:52:57.000Z", 15 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 16 | "reminders": { 17 | "useDefault": true 18 | }, 19 | "htmlLink": "https://www.google.com/calendar/event?eid=HikHg9bQzIk2dWtpdTRwM3Zpazd0cHMwcjRfMjAxMTEwMDNUMTYzMDAwWiBncWViMGk2djczN2tmdTVtZDBmM2V1a2psZ0Bn", 20 | "sequence": 2, 21 | "updated": "2012-01-09T19:39:49.000Z", 22 | "summary": "Staff Meeting", 23 | "recurrence": [ 24 | "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20120109T075959Z;BYDAY=MO" 25 | ], 26 | "start": { 27 | "timeZone": "America/Los_Angeles", 28 | "dateTime": "2011-10-03T09:30:00-07:00" 29 | }, 30 | "etag": "\"2652275978000000\"", 31 | "organizer": { 32 | "self": true, 33 | "displayName": "Some Person", 34 | "email": "klei8jnelo09nflqehnvfzipgs@group.calendar.google.com" 35 | }, 36 | "creator": { 37 | "displayName": "Some Person", 38 | "email": "some.person@gmail.com" 39 | }, 40 | "id": "fhru34kt6ikmr20knd2456l08n", 41 | "extendedProperties": { 42 | "shared": { 43 | "key": "value" 44 | } 45 | } 46 | }, 47 | { 48 | "status": "confirmed", 49 | "kind": "calendar#event", 50 | "end": { 51 | "timeZone": "America/Los_Angeles", 52 | "dateTime": "2011-10-03T11:00:00-07:00" 53 | }, 54 | "created": "2010-06-01T03:53:00.000Z", 55 | "iCalUID": "fhru34kt6ikmr20knd2456l08n@google.com", 56 | "reminders": { 57 | "useDefault": true 58 | }, 59 | "htmlLink": "https://www.google.com/calendar/event?eidi1wegp0jHmAFucjlhMXY0c2RqMjBpYXNlNmNfMjAxMTEwMDNUMTczMDAwWiBncWViMGk2djczN2tmdTVtZDBmM2V1a2psZ0Bn", 60 | "sequence": 2, 61 | "updated": "2012-01-13T18:24:01.000Z", 62 | "summary": "Skype Meeting", 63 | "recurrence": [ 64 | "RRULE:FREQ=WEEKLY;UNTIL=20120109T183000Z" 65 | ], 66 | "start": { 67 | "timeZone": "America/Los_Angeles", 68 | "dateTime": "2011-10-03T10:30:00-07:00" 69 | }, 70 | "etag": "\"2652958082000000\"", 71 | "organizer": { 72 | "self": true, 73 | "displayName": "Some Person", 74 | "email": "some.person@gmail.com" 75 | }, 76 | "creator": { 77 | "displayName": "Some Person", 78 | "email": "some.person@gmail.com" 79 | }, 80 | "id": "fhru34kt6ikmr20knd2456l08n", 81 | "extendedProperties": { 82 | "shared": { 83 | "key": "value" 84 | } 85 | } 86 | }, 87 | { 88 | "status": "confirmed", 89 | "kind": "calendar#event", 90 | "end": { 91 | "timeZone": "America/Los_Angeles", 92 | "dateTime": "2012-01-09T10:15:00-08:00" 93 | }, 94 | "created": "2012-01-09T19:39:50.000Z", 95 | "reminders": { 96 | "useDefault": true 97 | }, 98 | "htmlLink": "https://www.google.com/calendar/event?eid=uIf5JKmaPdhvNzRwM2FiYTQ4NTJqNmI5azZsMGoyYmExNmwyM2NiYTE4cDJqOGg5cDc0bzRhZGhoNmtfMjAxMjAxMDlUMTcxNTAwWiBncWViMGk2djczN2tmdTVtZDBmM2V1a2psZ0Bn", 99 | "sequence": 3, 100 | "updated": "2012-01-23T17:38:31.000Z", 101 | "summary": "Sales Staff Meeting", 102 | "recurrence": [ 103 | "RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20120123T171459Z;BYDAY=MO" 104 | ], 105 | "start": { 106 | "timeZone": "America/Los_Angeles", 107 | "dateTime": "2012-01-09T09:15:00-08:00" 108 | }, 109 | "etag": "\"2654680622000000\"", 110 | "organizer": { 111 | "self": true, 112 | "displayName": "Some Person", 113 | "email": "some.person@gmail.com" 114 | }, 115 | "creator": { 116 | "displayName": "Some Person", 117 | "email": "some.person@gmail.com" 118 | }, 119 | "id": "_75342gho74p3aba4852j6b9k6l0j2ba16l23cba18p2j8h9p74o4adhh6k", 120 | "extendedProperties": { 121 | "shared": { 122 | "key": "value" 123 | } 124 | } 125 | } 126 | ], 127 | "updated": "2014-11-15T22:32:46.965Z", 128 | "summary": "My Events Calendar", 129 | "etag": "\"1234567890123000\"", 130 | "timeZone": "America/Los_Angeles", 131 | "accessRole": "owner" 132 | } -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Google Calendar 2 | 3 | A fast lightweight and minimalist wrapper around the {Google Calendar}[https://www.google.com/calendar/] api. 4 | 5 | {Gem Version}[http://badge.fury.io/rb/google_calendar] {Build Status}[https://travis-ci.org/northworld/google_calendar] {}[https://codeclimate.com/github/northworld/google_calendar] {}[https://codeclimate.com/github/northworld/google_calendar] 6 | == Install 7 | [sudo] gem install 'google_calendar' 8 | 9 | == Setup 10 | 11 | Important Changes: Google no longer supports the 'urn:ietf:wg:oauth:2.0:oob' out-of-band OAuth method. You must setup your OAuth Credentials with a publically accessible URL where you can grab your code from the URL paramaters after approving the OAuth request. If your product is a Web Application, you can automate this set up by pointing the redirect_url to the appropriate method of your application. 12 | 13 | Obtain a Client ID and Secret 14 | 15 | 1. Go to the {Google Developers Console}[https://console.developers.google.com/]. 16 | 1. Select a project, or create a new one (at the top of the page). 17 | 1. In the sidebar on the left, select Library. 18 | 1. Type in 'Google Calendar' in the search box and click on 'Google Calendar API' in the results. 19 | 1. Click on the 'Enable' link at the top of the page. 20 | 1. In the sidebar on the left, select Credentials. 21 | 1. If you haven't done so already, create your 'OAuth client ID' by clicking 'Create Credentials -> OAuth client ID'. Choose Web Application as the type. 22 | 1. You must also provide an "Authorized redirect URIs" which is a publically reachable URL where google will redirect to after the OAuth approval. Google will append the paramater 'code=' to this URL during redirection which will include the necessary code to authenticate the usage of this application. 23 | 1. Take note of the Client ID and Client Secret as you'll need to add it to your code later. 24 | 1. In the sidebar on the left, select "OAuth consent screen". You must setup your Application Name, and add "Test users" if this is for personal use (i.e. you are setting up links to at limited set of known users' calendars), or "Publish" your app if this is for public use. 25 | 26 | Find your calendar ID 27 | 28 | 1. Visit {Google Calendar}[https://www.google.com/calendar/] in your web browser. 29 | 1. In the calendar list on the left, click the three vertical dots next to the appropriate calendar, then select 'Settings and sharing'. 30 | 1. From the left toolbar, choose 'Integrate Calendar'. 31 | 1. In the Integrate Calendar section, locate the Calendar ID at the top of the section. 32 | 1. Copy the Calendar ID. 33 | 34 | == Usage (readme_code.rb) 35 | require 'rubygems' 36 | require 'google_calendar' 37 | 38 | # Create an instance of the calendar. 39 | cal = Google::Calendar.new(:client_id => YOUR_CLIENT_ID, 40 | :client_secret => YOUR_SECRET, 41 | :calendar => YOUR_CALENDAR_ID, 42 | :redirect_url => YOUR_REDIRECT_URL # This must match a url you permitted in your OAuth setting 43 | ) 44 | 45 | puts "Do you already have a refresh token? (y/n)" 46 | has_token = $stdin.gets.chomp 47 | 48 | if has_token.downcase != 'y' 49 | 50 | # A user needs to approve access in order to work with their calendars. 51 | puts "Visit the following web page in your browser and approve access." 52 | puts cal.authorize_url 53 | puts "\nCopy the code out of the paramters after Google redirects you to your provided redirect_url" 54 | 55 | # Pass the ONE TIME USE access code here to login and get a refresh token that you can use for access from now on. 56 | refresh_token = cal.login_with_auth_code( $stdin.gets.chomp ) 57 | 58 | puts "\nMake sure you SAVE YOUR REFRESH TOKEN so you don't have to prompt the user to approve access again." 59 | puts "your refresh token is:\n\t#{refresh_token}\n" 60 | puts "Press return to continue" 61 | $stdin.gets.chomp 62 | 63 | else 64 | 65 | puts "Enter your refresh token" 66 | refresh_token = $stdin.gets.chomp 67 | cal.login_with_refresh_token(refresh_token) 68 | 69 | # Note: You can also pass your refresh_token to the constructor and it will login at that time. 70 | 71 | end 72 | 73 | event = cal.create_event do |e| 74 | e.title = 'A Cool Event' 75 | e.start_time = Time.now 76 | e.end_time = Time.now + (60 * 60) # seconds * min 77 | end 78 | 79 | puts event 80 | 81 | event = cal.find_or_create_event_by_id(event.id) do |e| 82 | e.title = 'An Updated Cool Event' 83 | e.end_time = Time.now + (60 * 60 * 2) # seconds * min * hours 84 | end 85 | 86 | puts event 87 | 88 | # All events 89 | puts cal.events 90 | 91 | # Query events 92 | puts cal.find_events('your search string') 93 | 94 | This sample code is located in readme_code.rb in the root folder. 95 | 96 | == Ruby Support 97 | The current google_calendar gem supports Ruby 2.1 and higher -- because of the json gem dependency. We maintain support for Ruby 1.8.7, 1.9.3 and 2.0 on different branches. 98 | 99 | == Notes 100 | * This is not a complete implementation of the calendar api, it just includes the features we needed to support our internal calendar integration. Feel free to add additional features and we will happily integrate them. 101 | * Did you get an SSL exception? If so take a look at this: https://gist.github.com/fnichol/867550 102 | 103 | == Contributing to google_calendar 104 | 105 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 106 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 107 | * Fork the project 108 | * Start a feature/bugfix branch 109 | * Commit and push until you are happy with your contribution 110 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 111 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 112 | 113 | == Running Tests 114 | The first time you run +rake+ +test+ Rake will copy over +.env.test+ 115 | to +.env+ for use by Dotenv. You can also use +.env.default+ as your 116 | own starting point, just remember to copy it over to +.env+ before 117 | running tests. 118 | 119 | You can modify +.env+ with your own credentials and don't worry about 120 | accidentally committing to the repo as +.env+ is in the +.gitignore+. 121 | 122 | == Copyright 123 | 124 | Copyright (c) 2010-2022 Northworld, LLC. See LICENSE.txt for further details. 125 | -------------------------------------------------------------------------------- /lib/google/connection.rb: -------------------------------------------------------------------------------- 1 | require 'signet/oauth_2/client' 2 | 3 | module Google 4 | 5 | # 6 | # This is a utility class that communicates with the google calendar api. 7 | # 8 | class Connection 9 | BASE_URI = "https://www.googleapis.com/calendar/v3" 10 | TOKEN_URI ="https://accounts.google.com/o/oauth2/token" 11 | AUTH_URI = "https://accounts.google.com/o/oauth2/auth" 12 | DEFAULT_SCOPE = "https://www.googleapis.com/auth/calendar" 13 | 14 | attr_accessor :client 15 | 16 | def self.new_with_service_account(params) 17 | client = Signet::OAuth2::Client.new( 18 | :scope => params.fetch(:scope, DEFAULT_SCOPE), 19 | :issuer => params[:client_id], 20 | :audience => TOKEN_URI, 21 | :token_credential_uri => TOKEN_URI, 22 | :signing_key => params[:signing_key], 23 | :person => params[:person] 24 | ) 25 | Connection.new(params, client) 26 | end 27 | 28 | # 29 | # A utility method used to centralize the creation of connections 30 | # 31 | def self.factory(params) # :nodoc 32 | Connection.new( 33 | :client_id => params[:client_id], 34 | :client_secret => params[:client_secret], 35 | :refresh_token => params[:refresh_token], 36 | :redirect_url => params[:redirect_url], 37 | :state => params[:state] 38 | ) 39 | end 40 | 41 | # 42 | # Prepare a connection to google for fetching a calendar events 43 | # 44 | # the +params+ paramater accepts 45 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/) 46 | # * :client_secret => the client secret you received from Google after registering your application with them. 47 | # * :redirect_uri => the url where your users will be redirected to after they have successfully permitted access to their calendars." 48 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again) 49 | # * :scope => Optional. The scope of the access request, expressed either as an Array or as a space-delimited String. 50 | # 51 | def initialize(params, client=nil) 52 | 53 | raise ArgumentError unless client || Connection.credentials_provided?(params) 54 | 55 | @client = client || Signet::OAuth2::Client.new( 56 | :client_id => params[:client_id], 57 | :client_secret => params[:client_secret], 58 | :redirect_uri => params[:redirect_url], 59 | :refresh_token => params[:refresh_token], 60 | :state => params[:state], 61 | :authorization_uri => AUTH_URI, 62 | :token_credential_uri => TOKEN_URI, 63 | :scope => params.fetch(:scope, DEFAULT_SCOPE) 64 | ) 65 | 66 | # try to get an access token if possible. 67 | if params[:refresh_token] 68 | @client.refresh_token = params[:refresh_token] 69 | @client.grant_type = 'refresh_token' 70 | end 71 | 72 | if params[:refresh_token] || params[:signing_key] 73 | Connection.get_new_access_token(@client) 74 | end 75 | 76 | end 77 | 78 | # 79 | # The URL you need to send a user in order to let them grant you access to their calendars. 80 | # 81 | def authorize_url 82 | @client.authorization_uri 83 | end 84 | 85 | # 86 | # The single use auth code that google uses during the auth process. 87 | # 88 | def auth_code 89 | @client.code 90 | end 91 | 92 | # 93 | # The current access token. Used during a session, typically expires in a hour. 94 | # 95 | def access_token 96 | @client.access_token 97 | end 98 | 99 | # 100 | # The refresh token is used to obtain a new access token. It remains valid until a user revokes access. 101 | # 102 | def refresh_token 103 | @client.refresh_token 104 | end 105 | 106 | # 107 | # Convenience method used to streamline the process of logging in with a auth code. 108 | # Returns the refresh token. 109 | # 110 | def login_with_auth_code(auth_code) 111 | @client.code = auth_code 112 | Connection.get_new_access_token(@client) 113 | @client.refresh_token 114 | end 115 | 116 | # 117 | # Convenience method used to streamline the process of logging in with a refresh token. 118 | # 119 | def login_with_refresh_token(refresh_token) 120 | @client.refresh_token = refresh_token 121 | @client.grant_type = 'refresh_token' 122 | Connection.get_new_access_token(@client) 123 | end 124 | 125 | # 126 | # Send a request to google. 127 | # 128 | def send(path, method, content = '') 129 | 130 | uri = BASE_URI + path 131 | response = @client.fetch_protected_resource( 132 | :uri => uri, 133 | :method => method, 134 | :body => content, 135 | :headers => {'Content-type' => 'application/json'} 136 | ) 137 | 138 | check_for_errors(response) 139 | 140 | return response 141 | end 142 | 143 | protected 144 | 145 | # 146 | # Utility method to centralize the process of getting an access token. 147 | # 148 | def self.get_new_access_token(client) #:nodoc: 149 | begin 150 | client.fetch_access_token! 151 | rescue Signet::AuthorizationError 152 | raise HTTPAuthorizationFailed 153 | end 154 | end 155 | 156 | # 157 | # Check for common HTTP Errors and raise the appropriate response. 158 | # Note: error 401 (InvalidCredentialsError) is handled by Signet. 159 | # 160 | def check_for_errors(response) #:nodoc 161 | case response.status 162 | when 400 then raise HTTPRequestFailed, response.body 163 | when 403 then parse_403_error(response) 164 | when 404 then raise HTTPNotFound, response.body 165 | when 409 then raise RequestedIdentifierAlreadyExistsError, response.body 166 | when 410 then raise GoneError, response.body 167 | when 412 then raise PreconditionFailedError, response.body 168 | when 500 then raise BackendError, response.body 169 | end 170 | end 171 | 172 | # 173 | # Utility method to centralize handling of 403 errors. 174 | # 175 | def parse_403_error(response) 176 | case JSON.parse(response.body)["error"]["message"] 177 | when "Forbidden" then raise ForbiddenError, response.body 178 | when "Daily Limit Exceeded" then raise DailyLimitExceededError, response.body 179 | when "User Rate Limit Exceeded" then raise UserRateLimitExceededError, response.body 180 | when "Rate Limit Exceeded" then raise RateLimitExceededError, response.body 181 | when "Calendar usage limits exceeded." then raise CalendarUsageLimitExceededError, response.body 182 | else raise ForbiddenError, response.body 183 | end 184 | end 185 | 186 | # 187 | # Utility method to centralize credential validation. 188 | # 189 | def self.credentials_provided?(params) #:nodoc: 190 | blank = /[^[:space:]]/ 191 | !(params[:client_id] !~ blank) && !(params[:client_secret] !~ blank) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/google/event.rb: -------------------------------------------------------------------------------- 1 | require 'time' 2 | require 'json' 3 | require 'timezone_parser' 4 | 5 | module Google 6 | 7 | # 8 | # Represents a Google Event. 9 | # 10 | # === Attributes 11 | # 12 | # * +id+ - The google assigned id of the event (nil until saved). Read Write. 13 | # * +status+ - The status of the event (confirmed, tentative or cancelled). Read only. 14 | # * +title+ - The title of the event. Read Write. 15 | # * +description+ - The content of the event. Read Write. 16 | # * +location+ - The location of the event. Read Write. 17 | # * +start_time+ - The start time of the event (Time object, defaults to now). Read Write. 18 | # * +end_time+ - The end time of the event (Time object, defaults to one hour from now). Read Write. 19 | # * +recurrence+ - A hash containing recurrence info for repeating events. Read write. 20 | # * +calendar+ - What calendar the event belongs to. Read Write. 21 | # * +all_day+ - Does the event run all day. Read Write. 22 | # * +quickadd+ - A string that Google parses when setting up a new event. If set and then saved it will take priority over any attributes you have set. Read Write. 23 | # * +reminders+ - A hash containing reminders. Read Write. 24 | # * +attendees+ - An array of hashes containing information about attendees. Read Write 25 | # * +transparency+ - Does the event 'block out space' on the calendar. Valid values are true, false or 'transparent', 'opaque'. Read Write. 26 | # * +duration+ - The duration of the event in seconds. Read only. 27 | # * +html_link+ - An absolute link to this event in the Google Calendar Web UI. Read only. 28 | # * +raw+ - The full google json representation of the event. Read only. 29 | # * +visibility+ - The visibility of the event (*'default'*, 'public', 'private', 'confidential'). Read Write. 30 | # * +extended_properties+ - Custom properties which may be shared or private. Read Write 31 | # * +guests_can_invite_others+ - Whether attendees other than the organizer can invite others to the event (*true*, false). Read Write. 32 | # * +guests_can_see_other_guests+ - Whether attendees other than the organizer can see who the event's attendees are (*true*, false). Read Write. 33 | # * +send_notifications+ - Whether to send notifications about the event update (true, *false*). Write only. 34 | # 35 | class Event 36 | attr_reader :id, :raw, :html_link, :status, :transparency, :visibility 37 | attr_writer :reminders, :recurrence, :extended_properties 38 | attr_accessor :title, :location, :calendar, :quickadd, :attendees, :description, :creator_name, :color_id, :guests_can_invite_others, :guests_can_see_other_guests, :send_notifications, :new_event_with_id_specified 39 | 40 | # 41 | # Create a new event, and optionally set it's attributes. 42 | # 43 | # ==== Example 44 | # 45 | # event = Google::Event.new 46 | # event.calendar = AnInstanceOfGoogleCalendaer 47 | # event.id = "0123456789abcdefghijklmopqrstuv" 48 | # event.start_time = Time.now 49 | # event.end_time = Time.now + (60 * 60) 50 | # event.recurrence = {'freq' => 'monthly'} 51 | # event.title = "Go Swimming" 52 | # event.description = "The polar bear plunge" 53 | # event.location = "In the arctic ocean" 54 | # event.transparency = "opaque" 55 | # event.visibility = "public" 56 | # event.reminders = {'useDefault' => false, 'overrides' => ['minutes' => 10, 'method' => "popup"]} 57 | # event.attendees = [ 58 | # {'email' => 'some.a.one@gmail.com', 'displayName' => 'Some A One', 'responseStatus' => 'tentative'}, 59 | # {'email' => 'some.b.one@gmail.com', 'displayName' => 'Some B One', 'responseStatus' => 'tentative'} 60 | # ] 61 | # event.extendedProperties = {'shared' => {'custom_str' => 'some custom string'}} 62 | # event.guests_can_invite_others = false 63 | # event.guests_can_see_other_guests = false 64 | # event.send_notifications = true 65 | # 66 | def initialize(params = {}) 67 | [:id, :status, :raw, :html_link, :title, :location, :calendar, :quickadd, :attendees, :description, :reminders, :recurrence, :start_time, :end_time, :color_id, :extended_properties, :guests_can_invite_others, :guests_can_see_other_guests, :send_notifications].each do |attribute| 68 | instance_variable_set("@#{attribute}", params[attribute]) 69 | end 70 | 71 | self.visibility = params[:visibility] 72 | self.transparency = params[:transparency] 73 | self.all_day = params[:all_day] if params[:all_day] 74 | self.creator_name = params[:creator]['displayName'] if params[:creator] 75 | self.new_event_with_id_specified = !!params[:new_event_with_id_specified] 76 | end 77 | 78 | # 79 | # Sets the id of the Event. 80 | # 81 | def id=(id) 82 | @id = Event.parse_id(id) unless id.nil? 83 | end 84 | 85 | # 86 | # Sets the start time of the Event. Must be a Time object or a parse-able string representation of a time. 87 | # 88 | def start_time=(time) 89 | @start_time = Event.parse_time(time) 90 | end 91 | 92 | # 93 | # Get the start_time of the event. 94 | # 95 | # If no time is set (i.e. new event) it defaults to the current time. 96 | # 97 | def start_time 98 | @start_time ||= Time.now.utc 99 | (@start_time.is_a? String) ? @start_time : @start_time.xmlschema 100 | end 101 | 102 | # 103 | # Get the end_time of the event. 104 | # 105 | # If no time is set (i.e. new event) it defaults to one hour in the future. 106 | # 107 | def end_time 108 | @end_time ||= Time.now.utc + (60 * 60) # seconds * min 109 | (@end_time.is_a? String) ? @end_time : @end_time.xmlschema 110 | end 111 | 112 | # 113 | # Sets the end time of the Event. Must be a Time object or a parse-able string representation of a time. 114 | # 115 | def end_time=(time) 116 | @end_time = Event.parse_time(time) 117 | end 118 | 119 | # 120 | # Returns whether the Event is an all-day event, based on whether the event starts at the beginning and ends at the end of the day. 121 | # 122 | def all_day? 123 | time = (@start_time.is_a? String) ? Time.parse(@start_time) : @start_time.dup.utc 124 | duration % (24 * 60 * 60) == 0 && time == Time.local(time.year,time.month,time.day) 125 | end 126 | 127 | # 128 | # Makes an event all day, by setting it's start time to the passed in time and it's end time 24 hours later. 129 | # Note: this will clobber both the start and end times currently set. 130 | # 131 | def all_day=(time) 132 | if time.class == String 133 | time = Time.parse(time) 134 | end 135 | @start_time = time.strftime("%Y-%m-%d") 136 | @end_time = (time + 24*60*60).strftime("%Y-%m-%d") 137 | end 138 | 139 | # 140 | # Duration of the event in seconds 141 | # 142 | def duration 143 | Time.parse(end_time) - Time.parse(start_time) 144 | end 145 | 146 | # 147 | # Stores reminders for this event. Multiple reminders are allowed. 148 | # 149 | # Examples 150 | # 151 | # event = cal.create_event do |e| 152 | # e.title = 'Some Event' 153 | # e.start_time = Time.now + (60 * 10) 154 | # e.end_time = Time.now + (60 * 60) # seconds * min 155 | # e.reminders = { 'useDefault' => false, 'overrides' => [{method: 'email', minutes: 4}, {method: 'popup', minutes: 60}, {method: 'sms', minutes: 30}]} 156 | # end 157 | # 158 | # event = Event.new :start_time => "2012-03-31", :end_time => "2012-04-03", :reminders => { 'useDefault' => false, 'overrides' => [{'minutes' => 10, 'method' => "popup"}]} 159 | # 160 | def reminders 161 | @reminders ||= {} 162 | end 163 | 164 | # 165 | # Stores recurrence rules for repeating events. 166 | # 167 | # Allowed contents: 168 | # :freq => frequence information ("daily", "weekly", "monthly", "yearly") REQUIRED 169 | # :count => how many times the repeating event should occur OPTIONAL 170 | # :until => Time class, until when the event should occur OPTIONAL 171 | # :interval => how often should the event occur (every "2" weeks, ...) OPTIONAL 172 | # :byday => if frequence is "weekly", contains ordered (starting with OPTIONAL 173 | # Sunday)comma separated abbreviations of days the event 174 | # should occur on ("su,mo,th") 175 | # if frequence is "monthly", can specify which day of month 176 | # the event should occur on ("2mo" - second Monday, "-1th" - last Thursday, 177 | # allowed indices are 1,2,3,4,-1) 178 | # 179 | # Note: The hash should not contain :count and :until keys simultaneously. 180 | # 181 | # ===== Example 182 | # event = cal.create_event do |e| 183 | # e.title = 'Work-day Event' 184 | # e.start_time = Time.now 185 | # e.end_time = Time.now + (60 * 60) # seconds * min 186 | # e.recurrence = {freq: "weekly", byday: "mo,tu,we,th,fr"} 187 | # end 188 | # 189 | def recurrence 190 | @recurrence ||= {} 191 | end 192 | 193 | # 194 | # Stores custom data within extended properties which can be shared or private. 195 | # 196 | # Allowed contents: 197 | # :private => a hash containing custom key/values (strings) private to the event OPTIONAL 198 | # :shared => a hash containing custom key/values (strings) shared with others OPTIONAL 199 | # 200 | # Note: Both private and shared can be specified at once 201 | # 202 | # ===== Example 203 | # event = cal.create_event do |e| 204 | # e.title = 'Work-day Event' 205 | # e.start_time = Time.now 206 | # e.end_time = Time.now + (60 * 60) # seconds * min 207 | # e.extended_properties = {'shared' => {'prop1' => 'value 1'}} 208 | # end 209 | # 210 | def extended_properties 211 | @extended_properties ||= {} 212 | end 213 | 214 | # 215 | # Utility method that simplifies setting the transparency of an event. 216 | # You can pass true or false. Defaults to transparent. 217 | # 218 | def transparency=(val) 219 | if val == true || val.to_s.downcase == 'transparent' 220 | @transparency = 'transparent' 221 | else 222 | @transparency = 'opaque' 223 | end 224 | end 225 | 226 | # 227 | # Returns true if the event is transparent otherwise returns false. 228 | # Transparent events do not block time on a calendar. 229 | # 230 | def transparent? 231 | @transparency == "transparent" 232 | end 233 | 234 | # 235 | # Returns true if the event is opaque otherwise returns false. 236 | # Opaque events block time on a calendar. 237 | # 238 | def opaque? 239 | @transparency == "opaque" 240 | end 241 | 242 | # 243 | # Sets the visibility of the Event. 244 | # 245 | def visibility=(val) 246 | if val 247 | @visibility = Event.parse_visibility(val) 248 | else 249 | @visibility = "default" 250 | end 251 | end 252 | 253 | # 254 | # Convenience method used to build an array of events from a Google feed. 255 | # 256 | def self.build_from_google_feed(response, calendar) 257 | events = response['items'] ? response['items'] : [response] 258 | events.collect {|e| new_from_feed(e, calendar)}.flatten 259 | end 260 | 261 | # 262 | # Google JSON representation of an event object. 263 | # 264 | def to_json 265 | attributes = { 266 | "summary" => title, 267 | "visibility" => visibility, 268 | "transparency" => transparency, 269 | "description" => description, 270 | "location" => location, 271 | "start" => time_or_all_day(start_time), 272 | "end" => time_or_all_day(end_time), 273 | "reminders" => reminders_attributes, 274 | "guestsCanInviteOthers" => guests_can_invite_others, 275 | "guestsCanSeeOtherGuests" => guests_can_see_other_guests 276 | } 277 | 278 | if id 279 | attributes["id"] = id 280 | end 281 | 282 | if timezone_needed? 283 | attributes['start'].merge!(local_timezone_attributes) 284 | attributes['end'].merge!(local_timezone_attributes) 285 | end 286 | 287 | attributes.merge!(recurrence_attributes) 288 | attributes.merge!(color_attributes) 289 | attributes.merge!(attendees_attributes) 290 | attributes.merge!(extended_properties_attributes) 291 | 292 | JSON.generate attributes 293 | end 294 | 295 | # 296 | # Hash representation of colors 297 | # 298 | def color_attributes 299 | return {} unless color_id 300 | { "colorId" => "#{color_id}" } 301 | end 302 | 303 | # 304 | # JSON representation of colors 305 | # 306 | def color_json 307 | color_attributes.to_json 308 | end 309 | 310 | # 311 | # Hash representation of attendees 312 | # 313 | def attendees_attributes 314 | return {} unless @attendees 315 | 316 | attendees = @attendees.map do |attendee| 317 | attendee.select { |k,_v| ['displayName', 'email', 'responseStatus'].include?(k) } 318 | end 319 | 320 | { "attendees" => attendees } 321 | end 322 | 323 | # 324 | # JSON representation of attendees 325 | # 326 | def attendees_json 327 | attendees_attributes.to_json 328 | end 329 | 330 | # 331 | # Hash representation of a reminder 332 | # 333 | def reminders_attributes 334 | if reminders && reminders.is_a?(Hash) && reminders['overrides'] 335 | 336 | { "useDefault" => false, "overrides" => reminders['overrides'] } 337 | else 338 | { "useDefault" => true} 339 | end 340 | end 341 | 342 | # 343 | # JSON representation of a reminder 344 | # 345 | def reminders_json 346 | reminders_attributes.to_json 347 | end 348 | 349 | # 350 | # Timezone info is needed only at recurring events 351 | # 352 | def timezone_needed? 353 | is_recurring_event? 354 | end 355 | 356 | # 357 | # Hash representation of local timezone 358 | # 359 | def local_timezone_attributes 360 | tz = Time.now.getlocal.zone 361 | tz_name = TimezoneParser::getTimezones(tz).last 362 | { "timeZone" => tz_name } 363 | end 364 | 365 | # 366 | # JSON representation of local timezone 367 | # 368 | def local_timezone_json 369 | local_timezone_attributes.to_json 370 | end 371 | 372 | # 373 | # Hash representation of recurrence rules for repeating events 374 | # 375 | def recurrence_attributes 376 | return {} unless is_recurring_event? 377 | 378 | @recurrence[:until] = @recurrence[:until].strftime('%Y%m%dT%H%M%SZ') if @recurrence[:until] 379 | rrule = "RRULE:" + @recurrence.collect { |k,v| "#{k}=#{v}" }.join(';').upcase 380 | @recurrence[:until] = Time.parse(@recurrence[:until]) if @recurrence[:until] 381 | 382 | { "recurrence" => [rrule] } 383 | end 384 | 385 | # 386 | # JSON representation of recurrence rules for repeating events 387 | # 388 | def recurrence_json 389 | recurrence_attributes.to_json 390 | end 391 | 392 | # 393 | # Hash representation of extended properties 394 | # shared : whether this should handle shared or public properties 395 | # 396 | def extended_properties_attributes 397 | return {} unless @extended_properties && (@extended_properties['shared'] || @extended_properties['private']) 398 | 399 | { "extendedProperties" => @extended_properties.select {|k,_v| ['shared', 'private'].include?(k) } } 400 | end 401 | 402 | # 403 | # JSON representation of extended properties 404 | # shared : whether this should handle shared or public properties 405 | # 406 | def extended_properties_json 407 | extended_properties_attributes.to_json 408 | end 409 | 410 | # 411 | # String representation of an event object. 412 | # 413 | def to_s 414 | "Event Id '#{self.id}'\n\tStatus: #{status}\n\tTitle: #{title}\n\tStarts: #{start_time}\n\tEnds: #{end_time}\n\tLocation: #{location}\n\tDescription: #{description}\n\tColor: #{color_id}\n\n" 415 | end 416 | 417 | # 418 | # Saves an event. 419 | # Note: make sure to set the calendar before calling this method. 420 | # 421 | def save 422 | update_after_save(@calendar.save_event(self)) 423 | end 424 | 425 | # 426 | # Deletes an event. 427 | # Note: If using this on an event you created without using a calendar object, 428 | # make sure to set the calendar before calling this method. 429 | # 430 | def delete 431 | @calendar.delete_event(self) 432 | @id = nil 433 | end 434 | 435 | # 436 | # Returns true if the event will use quickadd when it is saved. 437 | # 438 | def use_quickadd? 439 | quickadd && id == nil 440 | end 441 | 442 | # 443 | # Returns true if this a new event. 444 | # 445 | def new_event? 446 | new_event_with_id_specified? || id == nil || id == '' 447 | end 448 | 449 | # 450 | # Returns true if notifications were requested to be sent 451 | # 452 | def send_notifications? 453 | !!send_notifications 454 | end 455 | 456 | 457 | private 458 | 459 | def new_event_with_id_specified? 460 | !!new_event_with_id_specified 461 | end 462 | 463 | def time_or_all_day(time) 464 | time = Time.parse(time) if time.is_a? String 465 | 466 | if all_day? 467 | { "date" => time.strftime("%Y-%m-%d") } 468 | else 469 | { "dateTime" => time.xmlschema } 470 | end 471 | end 472 | 473 | protected 474 | 475 | # 476 | # Create a new event from a google 'entry' 477 | # 478 | def self.new_from_feed(e, calendar) #:nodoc: 479 | params = {} 480 | %w(id status description location creator transparency updated reminders attendees visibility).each do |p| 481 | params[p.to_sym] = e[p] 482 | end 483 | 484 | params[:raw] = e 485 | params[:calendar] = calendar 486 | params[:title] = e['summary'] 487 | params[:color_id] = e['colorId'] 488 | params[:extended_properties] = e['extendedProperties'] 489 | params[:guests_can_invite_others] = e['guestsCanInviteOthers'] 490 | params[:guests_can_see_other_guests] = e['guestsCanSeeOtherGuests'] 491 | params[:html_link] = e['htmlLink'] 492 | params[:start_time] = Event.parse_json_time(e['start']) 493 | params[:end_time] = Event.parse_json_time(e['end']) 494 | params[:recurrence] = Event.parse_recurrence_rule(e['recurrence']) 495 | 496 | Event.new(params) 497 | end 498 | 499 | # 500 | # Parse recurrence rule 501 | # Returns hash with recurrence info 502 | # 503 | def self.parse_recurrence_rule(recurrence_entry) 504 | return {} unless recurrence_entry && recurrence_entry != [] 505 | 506 | rrule = /(?<=RRULE:)(.*)(?="\])/.match(recurrence_entry.to_s).to_s 507 | rhash = Hash[*rrule.downcase.split(/[=;]/)] 508 | 509 | rhash[:until] = Time.parse(rhash[:until]) if rhash[:until] 510 | rhash 511 | end 512 | 513 | # 514 | # Set the ID after google assigns it (only necessary when we are creating a new event) 515 | # 516 | def update_after_save(response) #:nodoc: 517 | return if @id && @id != '' 518 | @raw = JSON.parse(response.body) 519 | @id = @raw['id'] 520 | @html_link = @raw['htmlLink'] 521 | end 522 | 523 | # 524 | # A utility method used to centralize parsing of time in json format 525 | # 526 | def self.parse_json_time(time_hash) #:nodoc 527 | return nil unless time_hash 528 | 529 | if time_hash['date'] 530 | Time.parse(time_hash['date']).utc 531 | elsif time_hash['dateTime'] 532 | Time.parse(time_hash['dateTime']).utc 533 | else 534 | Time.now.utc 535 | end 536 | end 537 | 538 | # 539 | # A utility method used to centralize checking for recurring events 540 | # 541 | def is_recurring_event? #:nodoc 542 | @recurrence && (@recurrence[:freq] || @recurrence['FREQ'] || @recurrence['freq']) 543 | end 544 | 545 | # 546 | # A utility method used centralize time parsing. 547 | # 548 | def self.parse_time(time) #:nodoc 549 | raise ArgumentError, "Start Time must be either Time or String" unless (time.is_a?(String) || time.is_a?(Time)) 550 | (time.is_a? String) ? Time.parse(time) : time.dup.utc 551 | end 552 | 553 | # 554 | # Validates id format 555 | # 556 | def self.parse_id(id) 557 | if id.to_s =~ /\A[a-v0-9]{5,1024}\Z/ 558 | id 559 | else 560 | raise ArgumentError, "Event ID is invalid. Please check Google documentation: https://developers.google.com/google-apps/calendar/v3/reference/events/insert" 561 | end 562 | end 563 | 564 | # 565 | # Validates visibility value 566 | # 567 | def self.parse_visibility(visibility) 568 | raise ArgumentError, "Event visibility must be 'default', 'public', 'private' or 'confidential'." unless ['default', 'public', 'private', 'confidential'].include?(visibility) 569 | return visibility 570 | end 571 | 572 | end 573 | end 574 | -------------------------------------------------------------------------------- /lib/google/calendar.rb: -------------------------------------------------------------------------------- 1 | module Google 2 | 3 | # 4 | # Calendar is the main object you use to interact with events. 5 | # use it to find, create, update and delete them. 6 | # 7 | class Calendar 8 | 9 | attr_reader :id, :connection, :summary, :location, :description, :time_zone 10 | 11 | # 12 | # Setup and connect to the specified Google Calendar. 13 | # the +params+ paramater accepts 14 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/). REQUIRED 15 | # * :client_secret => the client secret you received from Google after registering your application with them. REQUIRED 16 | # * :redirect_url => the url where your users will be redirected to after they have successfully permitted access to their calendars. REQUIRED 17 | # * :calendar => the id of the calendar you would like to work with (see Readme.rdoc for instructions on how to find yours). REQUIRED 18 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 19 | # 20 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 21 | # 22 | # ==== Example 23 | # Google::Calendar.new(:client_id => YOUR_CLIENT_ID, 24 | # :client_secret => YOUR_SECRET, 25 | # :calendar => YOUR_CALENDAR_ID, 26 | # :redirect_url => "http://myapplicationurl.com/" 27 | # ) 28 | # 29 | def initialize(params={}, connection=nil) 30 | @connection = connection || Connection.factory(params) 31 | @id = params[:calendar] 32 | end 33 | 34 | # 35 | # Setup, connect and create a Google Calendar. 36 | # the +params+ paramater accepts 37 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/). REQUIRED 38 | # * :client_secret => the client secret you received from Google after registering your application with them. REQUIRED 39 | # * :redirect_url => the url where your users will be redirected to after they have successfully permitted access to their calendars. REQUIRED 40 | # * :summary => title of the calendar being created. OPTIONAL 41 | # * :location => geographic location of the calendar as free-form text. OPTIONAL 42 | # * :time_zone => the time zone of the calendar. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".) OPTIONAL 43 | # * :description => description of the calendar. OPTIONAL 44 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 45 | # 46 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 47 | # 48 | # ==== Example 49 | # Google::Calendar.create( 50 | # :client_id => YOUR_CLIENT_ID, 51 | # :client_secret => YOUR_SECRET, 52 | # :summary => 'Test Calendar', 53 | # :location => 'Somewhere', 54 | # :description => 'Test Calendar Description', 55 | # :time_zone => 'Europe/Zurich', 56 | # :redirect_url => "http://myapplicationurl.com/" 57 | # ) 58 | # 59 | def self.create(params={}, connection=nil) 60 | cal = new(params, connection) 61 | cal.instance_variable_set(:@summary, params[:summary]) 62 | cal.instance_variable_set(:@location, params[:location]) 63 | cal.instance_variable_set(:@description, params[:description]) 64 | cal.instance_variable_set(:@time_zone, params[:time_zone]) 65 | 66 | cal.save 67 | end 68 | 69 | # 70 | # Connect and retrieve a Google Calendar. 71 | # the +params+ paramater accepts 72 | # * :client_id => the client ID that you received from Google after registering your application with them (https://console.developers.google.com/). REQUIRED 73 | # * :client_secret => the client secret you received from Google after registering your application with them. REQUIRED 74 | # * :redirect_url => the url where your users will be redirected to after they have successfully permitted access to their calendars. REQUIRED 75 | # * :calendar => the id of the calendar you would like to work with (see Readme.rdoc for instructions on how to find yours). REQUIRED 76 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 77 | # 78 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 79 | # 80 | # ==== Example 81 | # Google::Calendar.get( 82 | # :client_id => YOUR_CLIENT_ID, 83 | # :client_secret => YOUR_SECRET, 84 | # :calendar => YOUR_CALENDAR_ID, 85 | # :redirect_url => "http://myapplicationurl.com/" 86 | # ) 87 | # 88 | def self.get(params={}, connection=nil) 89 | cal = new(params, connection) 90 | cal.retrieve_calendar 91 | end 92 | 93 | # 94 | # Connect and update a Google Calendar. 95 | # the +params+ paramater accepts 96 | # * :summary => title of the calendar being created. OPTIONAL 97 | # * :location => geographic location of the calendar as free-form text. OPTIONAL 98 | # * :time_zone => the time zone of the calendar. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".) OPTIONAL 99 | # * :description => description of the calendar. OPTIONAL 100 | # * :refresh_token => if a user has already given you access to their calendars, you can specify their refresh token here and you will be 'logged on' automatically (i.e. they don't need to authorize access again). OPTIONAL 101 | # 102 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 103 | # 104 | # ==== Example 105 | # google_calendar_object.update( 106 | # :summary => 'Test Calendar', 107 | # :location => 'Somewhere', 108 | # :description => 'Test Calendar Description', 109 | # :time_zone => 'Europe/Zurich', 110 | # ) 111 | # 112 | def update(params={}) 113 | instance_variable_set(:@summary, params[:summary]) 114 | instance_variable_set(:@location, params[:location]) 115 | instance_variable_set(:@description, params[:description]) 116 | instance_variable_set(:@time_zone, params[:time_zone]) 117 | 118 | response = 119 | send_calendar_request( 120 | "/#{@id}", 121 | :put, 122 | { 123 | summary: @summary, 124 | location: @location, 125 | description: @description, 126 | timeZone: @time_zone, 127 | }.to_json 128 | ) 129 | @raw = JSON.parse(response.body) 130 | self 131 | end 132 | 133 | # 134 | # Destroy a Google Calendar. 135 | # 136 | # See Readme.rdoc or readme_code.rb for an explication on the OAuth2 authorization process. 137 | # 138 | # ==== Example 139 | # google_calendar_object.destroy 140 | # 141 | def destroy 142 | send_calendar_request("/#{@id}", :delete) 143 | end 144 | 145 | # 146 | # The URL you need to send a user in order to let them grant you access to their calendars. 147 | # 148 | def authorize_url 149 | @connection.authorize_url 150 | end 151 | 152 | # 153 | # The single use auth code that google uses during the auth process. 154 | # 155 | def auth_code 156 | @connection.auth_code 157 | end 158 | 159 | # 160 | # The current access token. Used during a session, typically expires in a hour. 161 | # 162 | def access_token 163 | @connection.access_token 164 | end 165 | 166 | # 167 | # The refresh token is used to obtain a new access token. It remains valid until a user revokes access. 168 | # 169 | def refresh_token 170 | @connection.refresh_token 171 | end 172 | 173 | # 174 | # Convenience method used to streamline the process of logging in with a auth code. 175 | # 176 | def login_with_auth_code(auth_code) 177 | @connection.login_with_auth_code(auth_code) 178 | end 179 | 180 | # 181 | # Convenience method used to streamline the process of logging in with a refresh token. 182 | # 183 | def login_with_refresh_token(refresh_token) 184 | @connection.login_with_refresh_token(refresh_token) 185 | end 186 | 187 | # 188 | # Save a new calender. 189 | # Returns: 190 | # the calendar that was saved. 191 | # 192 | def save 193 | response = send_calendar_request("/", :post, {:summary => @summary}.to_json) 194 | update_after_save(response) 195 | end 196 | 197 | # 198 | # Get an existing calender. 199 | # Returns: 200 | # the calendar that was requested. 201 | # 202 | def retrieve_calendar 203 | response = send_calendar_request("/#{@id}", :get) 204 | @raw = JSON.parse(response.body) 205 | instance_variable_set(:@summary, @raw['summary']) 206 | instance_variable_set(:@location, @raw['location']) 207 | instance_variable_set(:@description, @raw['description']) 208 | instance_variable_set(:@time_zone, @raw['timeZone']) 209 | @html_link = @raw['htmlLink'] 210 | 211 | self 212 | end 213 | 214 | # 215 | # Find all of the events associated with this calendar. 216 | # Returns: 217 | # an empty array if nothing found. 218 | # an array with one element if only one found. 219 | # an array of events if many found. 220 | # 221 | def events 222 | event_lookup() 223 | end 224 | 225 | # 226 | # This is equivalent to running a search in the Google calendar web application. 227 | # Google does not provide a way to specify what attributes you would like to 228 | # search (i.e. title), by default it searches everything. 229 | # If you would like to find specific attribute value (i.e. title=Picnic), run a query 230 | # and parse the results. 231 | # 232 | # Note that it is not possible to query the extended properties using queries. 233 | # If you need to do so, use the alternate methods find_events_by_extended_property 234 | # and find_events_by_extended_property_in_range 235 | # 236 | # Returns: 237 | # an empty array if nothing found. 238 | # an array with one element if only one found. 239 | # an array of events if many found. 240 | # 241 | def find_events(query) 242 | event_lookup("?q=#{query}") 243 | end 244 | 245 | # 246 | # Find all of the events associated with this calendar that start in the given time frame. 247 | # The lower bound is inclusive, whereas the upper bound is exclusive. 248 | # Events that overlap the range are included. 249 | # 250 | # the +options+ parameter accepts 251 | # :max_results => the maximum number of results to return defaults to 25 the largest number Google accepts is 2500 252 | # :order_by => how you would like the results ordered, can be either 'startTime' or 'updated'. Defaults to 'startTime'. Note: it must be 'updated' if expand_recurring_events is set to false. 253 | # :expand_recurring_events => When set to true each instance of a recurring event is returned. Defaults to true. 254 | # 255 | # Returns: 256 | # an empty array if nothing found. 257 | # an array with one element if only one found. 258 | # an array of events if many found. 259 | # 260 | def find_events_in_range(start_min, start_max, options = {}) 261 | formatted_start_min = encode_time(start_min) 262 | formatted_start_max = encode_time(start_max) 263 | query = "?timeMin=#{formatted_start_min}&timeMax=#{formatted_start_max}#{parse_options(options)}" 264 | event_lookup(query) 265 | end 266 | 267 | # 268 | # Find all events that are occurring at the time the method is run or later. 269 | # 270 | # the +options+ parameter accepts 271 | # :max_results => the maximum number of results to return defaults to 25 the largest number Google accepts is 2500 272 | # :order_by => how you would like the results ordered, can be either 'startTime' or 'updated'. Defaults to 'startTime'. Note: it must be 'updated' if expand_recurring_events is set to false. 273 | # :expand_recurring_events => When set to true each instance of a recurring event is returned. Defaults to true. 274 | # 275 | # Returns: 276 | # an empty array if nothing found. 277 | # an array with one element if only one found. 278 | # an array of events if many found. 279 | # 280 | def find_future_events(options={}) 281 | formatted_start_min = encode_time(Time.now) 282 | query = "?timeMin=#{formatted_start_min}#{parse_options(options)}" 283 | event_lookup(query) 284 | end 285 | 286 | # 287 | # Find all events that match at least one of the specified extended properties. 288 | # 289 | # the +extended_properties+ parameter is set up the same way that it is configured when creating an event 290 | # for example, providing the following hash { 'shared' => {'p1' => 'v1', 'p2' => v2} } will return the list of events 291 | # that contain either v1 for shared extended property p1 or v2 for p2. 292 | # 293 | # the +options+ parameter accepts 294 | # :max_results => the maximum number of results to return defaults to 25 the largest number Google accepts is 2500 295 | # :order_by => how you would like the results ordered, can be either 'startTime' or 'updated'. Defaults to 'startTime'. Note: it must be 'updated' if expand_recurring_events is set to false. 296 | # :expand_recurring_events => When set to true each instance of a recurring event is returned. Defaults to true. 297 | # 298 | # Returns: 299 | # an empty array if nothing found. 300 | # an array with one element if only one found. 301 | # an array of events if many found. 302 | # 303 | def find_events_by_extended_properties(extended_properties, options = {}) 304 | query = "?" + parse_extended_properties(extended_properties) + parse_options(options) 305 | event_lookup(query) 306 | end 307 | 308 | # 309 | # Find all events that match at least one of the specified extended properties within a given time frame. 310 | # The lower bound is inclusive, whereas the upper bound is exclusive. 311 | # Events that overlap the range are included. 312 | # 313 | # the +extended_properties+ parameter is set up the same way that it is configured when creating an event 314 | # for example, providing the following hash { 'shared' => {'p1' => 'v1', 'p2' => v2} } will return the list of events 315 | # that contain either v1 for shared extended property p1 or v2 for p2. 316 | # 317 | # the +options+ parameter accepts 318 | # :max_results => the maximum number of results to return defaults to 25 the largest number Google accepts is 2500 319 | # :order_by => how you would like the results ordered, can be either 'startTime' or 'updated'. Defaults to 'startTime'. Note: it must be 'updated' if expand_recurring_events is set to false. 320 | # :expand_recurring_events => When set to true each instance of a recurring event is returned. Defaults to true. 321 | # 322 | # Returns: 323 | # an empty array if nothing found. 324 | # an array with one element if only one found. 325 | # an array of events if many found. 326 | # 327 | def find_events_by_extended_properties_in_range(extended_properties, start_min, start_max, options = {}) 328 | formatted_start_min = encode_time(start_min) 329 | formatted_start_max = encode_time(start_max) 330 | base_query = parse_extended_properties(extended_properties) + parse_options(options) 331 | query = "?" + base_query + (base_query.empty? ? '' : '&') + "timeMin=#{formatted_start_min}&timeMax=#{formatted_start_max}" 332 | event_lookup(query) 333 | end 334 | 335 | # 336 | # Attempts to find the event specified by the id 337 | # Returns: 338 | # an empty array if nothing found. 339 | # an array with one element if only one found. 340 | # an array of events if many found. 341 | # 342 | def find_event_by_id(id) 343 | return nil unless id 344 | event_lookup("/#{id}") 345 | end 346 | 347 | # 348 | # Creates a new event and immediately saves it. 349 | # Returns the event 350 | # 351 | # ==== Examples 352 | # # Use a block 353 | # cal.create_event do |e| 354 | # e.title = "A New Event" 355 | # e.where = "Room 101" 356 | # end 357 | # 358 | # # Don't use a block (need to call save manually) 359 | # event = cal.create_event 360 | # event.title = "A New Event" 361 | # event.where = "Room 101" 362 | # event.save 363 | # 364 | def create_event(&blk) 365 | setup_event(Event.new, &blk) 366 | end 367 | 368 | # 369 | # Looks for the specified event id. 370 | # If it is found it, updates it's vales and returns it. 371 | # If the event is no longer on the server it creates a new one with the specified values. 372 | # Works like the create_event method. 373 | # 374 | def find_or_create_event_by_id(id, &blk) 375 | event = id ? find_event_by_id(id)[0] : nil 376 | 377 | if event 378 | setup_event(event, &blk) 379 | elsif id 380 | event = Event.new(id: id, new_event_with_id_specified: true) 381 | setup_event(event, &blk) 382 | else 383 | event = Event.new 384 | setup_event(event, &blk) 385 | end 386 | end 387 | 388 | # 389 | # Saves the specified event. 390 | # This is a callback used by the Event class. 391 | # 392 | def save_event(event) 393 | method = event.new_event? ? :post : :put 394 | body = event.use_quickadd? ? nil : event.to_json 395 | notifications = "sendNotifications=#{event.send_notifications?}" 396 | query_string = if event.use_quickadd? 397 | "/quickAdd?#{notifications}&text=#{event.title}" 398 | elsif event.new_event? 399 | "?#{notifications}" 400 | else # update existing event. 401 | "/#{event.id}?#{notifications}" 402 | end 403 | 404 | send_events_request(query_string, method, body) 405 | end 406 | 407 | # 408 | # Deletes the specified event. 409 | # This is a callback used by the Event class. 410 | # 411 | def delete_event(event) 412 | notifications = "sendNotifications=#{event.send_notifications?}" 413 | send_events_request("/#{event.id}?#{notifications}", :delete) 414 | end 415 | 416 | protected 417 | 418 | # 419 | # Set the ID after google assigns it (only necessary when we are creating a new event) 420 | # 421 | def update_after_save(response) #:nodoc: 422 | return if @id && @id != '' 423 | @raw = JSON.parse(response.body) 424 | @id = @raw['id'] 425 | @html_link = @raw['htmlLink'] 426 | 427 | self 428 | end 429 | 430 | # 431 | # Utility method used to centralize the parsing of common query parameters. 432 | # 433 | def parse_options(options) # :nodoc 434 | options[:max_results] ||= 25 435 | options[:order_by] ||= 'startTime' # other option is 'updated' 436 | options[:expand_recurring_events] ||= true 437 | query_string = "&orderBy=#{options[:order_by]}" 438 | query_string << "&maxResults=#{options[:max_results]}" 439 | query_string << "&singleEvents=#{options[:expand_recurring_events]}" 440 | query_string << "&q=#{options[:query]}" unless options[:query].nil? 441 | query_string 442 | end 443 | 444 | # 445 | # Utility method used to centralize the parsing of extended query parameters. 446 | # 447 | def parse_extended_properties(extended_properties) # :nodoc 448 | query_parts = [] 449 | ['shared', 'private'].each do |prop_type| 450 | next unless extended_properties[prop_type] 451 | query_parts << extended_properties[prop_type].map {|key, value| (prop_type == "shared" ? "sharedExtendedProperty=" : "privateExtendedProperty=") + "#{key}%3D#{value}" }.join("&") 452 | end 453 | query_parts.join('&') 454 | end 455 | 456 | # 457 | # Utility method to centralize time encoding. 458 | # 459 | def encode_time(time) #:nodoc: 460 | time.utc.strftime("%FT%TZ") 461 | end 462 | 463 | # 464 | # Utility method used to centralize event lookup. 465 | # 466 | def event_lookup(query_string = '') #:nodoc: 467 | begin 468 | response = send_events_request(query_string, :get) 469 | parsed_json = JSON.parse(response.body) 470 | @summary = parsed_json['summary'] 471 | events = Event.build_from_google_feed(parsed_json, self) || [] 472 | return events if events.empty? 473 | events.length > 1 ? events : [events[0]] 474 | rescue Google::HTTPNotFound 475 | return [] 476 | end 477 | end 478 | 479 | # 480 | # Utility method used to centralize event setup 481 | # 482 | def setup_event(event) #:nodoc: 483 | event.calendar = self 484 | if block_given? 485 | yield(event) 486 | end 487 | event.save 488 | event 489 | end 490 | 491 | # 492 | # Wraps the `send` method. Send a calendar related request to Google. 493 | # 494 | def send_calendar_request(path_and_query_string, method, content = '') 495 | @connection.send("/calendars#{path_and_query_string}", method, content) 496 | end 497 | 498 | # 499 | # Wraps the `send` method. Send an event related request to Google. 500 | # 501 | def send_events_request(path_and_query_string, method, content = '') 502 | @connection.send("/calendars/#{CGI::escape @id}/events#{path_and_query_string}", method, content) 503 | end 504 | end 505 | 506 | end 507 | -------------------------------------------------------------------------------- /test/test_google_calendar.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestGoogleCalendar < Minitest::Test 4 | include Google 5 | 6 | context "When connected" do 7 | 8 | setup do 9 | @client_mock = setup_mock_client 10 | 11 | @client_id = ENV['CLIENT_ID'] 12 | @client_secret = ENV['CLIENT_SECRET'] 13 | @refresh_token = ENV['REFRESH_TOKEN'] 14 | @calendar_id = ENV['CALENDAR_ID'] 15 | @access_token = ENV['ACCESS_TOKEN'] 16 | @redirect_url = ENV['REDIRECT_URL'] 17 | 18 | @calendar = Calendar.new(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, :refresh_token => @refresh_token, :calendar => @calendar_id) 19 | 20 | end 21 | 22 | context "a calendar" do 23 | 24 | should "generate auth url" do 25 | assert_equal @calendar.authorize_url.to_s, 'https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=671053090364-ntifn8rauvhib9h3vnsegi6dhfglk9ue.apps.googleusercontent.com&redirect_uri=https://mytesturl.com/&response_type=code&scope=https://www.googleapis.com/auth/calendar' 26 | end 27 | 28 | should "login with auth code" do 29 | @client_mock.stubs(:body).returns( get_mock_body("login_with_auth_code_success.json") ) 30 | @calendar.login_with_auth_code('4/QzBU-n6GXnHUkorG0fiu6AhoZtIjW53qKLOREiJWFpQ.wn0UfiyaDlEfEnp6UAPFm0EazsV1kwI') 31 | assert_nil @calendar.auth_code # the auth_code is discarded after it is used. 32 | assert_equal @calendar.access_token, @access_token 33 | assert_equal @calendar.refresh_token, '1/aJUy7pQzc4fUMX89BMMLeAfKcYteBKRMpQvf4fQFX0' 34 | end 35 | 36 | should "login with refresh token" do 37 | # no refresh_token specified 38 | cal = Calendar.new(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, :calendar => @calendar_id) 39 | @client_mock.stubs(:body).returns( get_mock_body("login_with_refresh_token_success.json") ) 40 | cal.login_with_refresh_token(@refresh_token) 41 | assert_equal @calendar.access_token, @access_token 42 | end 43 | 44 | should "login with get method" do 45 | cal = Calendar.get(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, 46 | :calendar => @calendar_id, :refresh_token => @refresh_token) 47 | @client_mock.stubs(:body).returns( get_mock_body("get_calendar.json") ) 48 | assert_equal cal.id, @calendar_id 49 | end 50 | 51 | should "update calendar" do 52 | cal = Calendar.get(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, 53 | :calendar => @calendar_id, :refresh_token => @refresh_token) 54 | @client_mock.stubs(:body).returns( get_mock_body("update_calendar.json") ) 55 | cal.update(:summary => 'Our Company', :description => "Work event list") 56 | assert_equal @calendar.id, @calendar_id 57 | end 58 | 59 | should "create calendar" do 60 | cal = Calendar.create(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, 61 | :refresh_token => @refresh_token, :summary => 'A New Calendar', :description => 'Our new calendar') 62 | assert_equal cal.summary, 'A New Calendar' 63 | end 64 | 65 | should "catch login with invalid credentials" do 66 | @client_mock.stubs(:status).returns(403) 67 | @client_mock.stubs(:body).returns( get_mock_body("403.json") ) 68 | assert_raises(HTTPAuthorizationFailed) do 69 | Calendar.new(:client_id => 'abadid', :client_secret => 'abadsecret', :redirect_url => @redirect_url, 70 | :refresh_token => @refresh_token, :calendar => @calendar_id) 71 | end 72 | end 73 | 74 | should "catch login with missing credentials" do 75 | assert_raises(ArgumentError) do 76 | @client_mock.stubs(:status).returns(401) 77 | @client_mock.stubs(:body).returns( get_mock_body("401.json") ) 78 | Calendar.new() 79 | end 80 | end 81 | 82 | should "accept a connection to re-use" do 83 | @client_mock.stubs(:body).returns( get_mock_body("events.json") ) 84 | reusable_connection = mock('Google::Connection') 85 | reusable_connection.expects(:send).returns(@client_mock) 86 | 87 | calendar = Calendar.new({:calendar => @calendar_id}, reusable_connection) 88 | calendar.events 89 | end 90 | 91 | should "throw ForbiddenError if not logged in" do 92 | @client_mock.stubs(:status).returns(403) 93 | @client_mock.stubs(:body).returns( get_mock_body("403_forbidden.json") ) 94 | 95 | assert_raises(ForbiddenError) do 96 | @calendar.find_event_by_id('1234') 97 | end 98 | end 99 | 100 | should "throw ForbiddenError if unknown 403 response" do 101 | @client_mock.stubs(:status).returns(403) 102 | @client_mock.stubs(:body).returns( get_mock_body("403_unknown.json") ) 103 | 104 | assert_raises(ForbiddenError) do 105 | @calendar.find_event_by_id('1234') 106 | end 107 | end 108 | 109 | end # login context 110 | 111 | context "and logged in" do 112 | setup do 113 | @calendar = Calendar.new(:client_id => @client_id, :client_secret => @client_secret, :redirect_url => @redirect_url, :refresh_token => @refresh_token, :calendar => @calendar_id) 114 | end 115 | 116 | should "find all events" do 117 | @client_mock.stubs(:body).returns( get_mock_body("events.json") ) 118 | assert_equal @calendar.events.length, 3 119 | end 120 | 121 | should "query events" do 122 | @client_mock.stubs(:body).returns( get_mock_body("query_events.json") ) 123 | event = @calendar.find_events('Test&gsessionid=12345') 124 | assert_equal event[0].title, 'Test Event' 125 | end 126 | 127 | should "find events in range" do 128 | now = Time.now.utc 129 | Time.stubs(:now).returns(now) 130 | start_min = now 131 | start_max = (now + 60*60*24) 132 | @calendar.expects(:event_lookup).with("?timeMin=#{start_min.strftime("%FT%TZ")}&timeMax=#{start_max.strftime("%FT%TZ")}&orderBy=startTime&maxResults=25&singleEvents=true") 133 | @calendar.find_events_in_range(start_min, start_max) 134 | end 135 | 136 | should "find events with shared extended property" do 137 | @calendar.expects(:event_lookup).with("?sharedExtendedProperty=p%3Dv&sharedExtendedProperty=q%3Dw&orderBy=startTime&maxResults=25&singleEvents=true") 138 | @calendar.find_events_by_extended_properties({'shared' => {'p' => 'v', 'q' => 'w'}}) 139 | end 140 | 141 | should "find events with shared extended property in range" do 142 | now = Time.now.utc 143 | Time.stubs(:now).returns(now) 144 | start_min = now 145 | start_max = (now + 60*60*24) 146 | @calendar.expects(:event_lookup).with("?sharedExtendedProperty=p%3Dv&orderBy=startTime&maxResults=25&singleEvents=true&timeMin=#{start_min.strftime("%FT%TZ")}&timeMax=#{start_max.strftime("%FT%TZ")}") 147 | @calendar.find_events_by_extended_properties_in_range({'shared' => {'p' => 'v'}}, start_min, start_max) 148 | end 149 | 150 | should "find future events" do 151 | now = Time.now.utc 152 | Time.stubs(:now).returns(now) 153 | formatted_time = now.strftime("%FT%TZ") 154 | @calendar.expects(:event_lookup).with("?timeMin=#{formatted_time}&orderBy=startTime&maxResults=25&singleEvents=true") 155 | @calendar.find_future_events() 156 | end 157 | 158 | should "find future events with query" do 159 | now = Time.now.utc 160 | Time.stubs(:now).returns(now) 161 | formatted_time = now.strftime("%FT%TZ") 162 | @calendar.expects(:event_lookup).with("?timeMin=#{formatted_time}&orderBy=startTime&maxResults=25&singleEvents=true&q=Test") 163 | @calendar.find_future_events({max_results: 25, order_by: :startTime, query: 'Test'}) 164 | end 165 | 166 | should "return multiple events in range as array" do 167 | @client_mock.stubs(:body).returns( get_mock_body("events.json") ) 168 | events = @calendar.events 169 | assert_equal events.class.to_s, "Array" 170 | end 171 | 172 | should "return one event in range as array" do 173 | @client_mock.stubs(:body).returns( get_mock_body("query_events.json") ) 174 | events = @calendar.events 175 | assert_equal events.class.to_s, "Array" 176 | end 177 | 178 | should "return one event in range as array from cancelled data" do 179 | @client_mock.stubs(:body).returns( get_mock_body("cancelled_events.json") ) 180 | events = @calendar.events 181 | assert_equal events.class.to_s, "Array" 182 | end 183 | 184 | should "return response of no events in range as array" do 185 | @client_mock.stubs(:body).returns( get_mock_body("empty_events.json") ) 186 | events = @calendar.events 187 | assert_equal events.class.to_s, "Array" 188 | assert_equal events, [] 189 | end 190 | 191 | should "find an event by id" do 192 | @client_mock.stubs(:body).returns( get_mock_body("find_event_by_id.json") ) 193 | event = @calendar.find_event_by_id('fhru34kt6ikmr20knd2456l08n') 194 | assert_equal event[0].id, 'fhru34kt6ikmr20knd2456l08n' 195 | end 196 | 197 | should "throw NotFound with invalid event id" do 198 | @client_mock.stubs(:status).returns(404) 199 | @client_mock.stubs(:body).returns( get_mock_body("404.json") ) 200 | assert_equal @calendar.find_event_by_id('1234'), [] 201 | end 202 | 203 | should "throw Request Failed with 400 error" do 204 | @client_mock.stubs(:status).returns(400) 205 | @client_mock.stubs(:body).returns( get_mock_body("400.json") ) 206 | 207 | assert_raises(HTTPRequestFailed) do 208 | @calendar.create_event do |e| 209 | e.title = 'New Event with no time' 210 | e.description = "A new event with &" 211 | e.location = "Joe's House & Backyard" 212 | end 213 | end 214 | end 215 | 216 | should "throw DailyLimitExceededError when limit exceeded" do 217 | @client_mock.stubs(:status).returns(403) 218 | @client_mock.stubs(:body).returns( get_mock_body("403_daily_limit.json") ) 219 | 220 | assert_raises(DailyLimitExceededError) do 221 | @calendar.find_event_by_id('1234') 222 | end 223 | end 224 | 225 | should "throw RateLimitExceededError when rate limit exceeded" do 226 | @client_mock.stubs(:status).returns(403) 227 | @client_mock.stubs(:body).returns( get_mock_body("403_rate_limit.json") ) 228 | 229 | assert_raises(RateLimitExceededError) do 230 | @calendar.find_event_by_id('1234') 231 | end 232 | end 233 | 234 | should "throw UserRateLimitExceededError when user rate limit exceeded" do 235 | @client_mock.stubs(:status).returns(403) 236 | @client_mock.stubs(:body).returns( get_mock_body("403_user_rate_limit.json") ) 237 | 238 | assert_raises(UserRateLimitExceededError) do 239 | @calendar.find_event_by_id('1234') 240 | end 241 | end 242 | 243 | should "throw CalendarUsageLimitExceededError when calendar rate limit exceeded" do 244 | @client_mock.stubs(:status).returns(403) 245 | @client_mock.stubs(:body).returns( get_mock_body("403_calendar_rate_limit.json") ) 246 | 247 | assert_raises(CalendarUsageLimitExceededError) do 248 | @calendar.find_event_by_id('1234') 249 | end 250 | end 251 | 252 | should "throw RequestedIdentifierAlreadyExistsError if bad eTag" do 253 | @client_mock.stubs(:status).returns(409) 254 | @client_mock.stubs(:body).returns( get_mock_body("409.json") ) 255 | 256 | assert_raises(RequestedIdentifierAlreadyExistsError) do 257 | @calendar.create_event do |e| 258 | e.id = 'duplicate' 259 | e.title = 'New Event' 260 | e.start_time = Time.now + (60 * 60) 261 | e.end_time = Time.now + (60 * 60 * 2) 262 | end 263 | end 264 | end 265 | 266 | should "throw GoneError if bad eTag" do 267 | @client_mock.stubs(:status).returns(410) 268 | @client_mock.stubs(:body).returns( get_mock_body("410.json") ) 269 | 270 | assert_raises(GoneError) do 271 | @calendar.find_event_by_id('deleted') 272 | end 273 | end 274 | 275 | should "throw PreconditionFailedError if bad eTag" do 276 | @client_mock.stubs(:status).returns(412) 277 | @client_mock.stubs(:body).returns( get_mock_body("412.json") ) 278 | 279 | assert_raises(PreconditionFailedError) do 280 | @calendar.find_event_by_id('1234') 281 | end 282 | end 283 | 284 | should "throw BackendError if Google is having issues" do 285 | @client_mock.stubs(:status).returns(500) 286 | @client_mock.stubs(:body).returns( get_mock_body("500.json") ) 287 | 288 | assert_raises(BackendError) do 289 | @calendar.find_event_by_id('1234') 290 | end 291 | end 292 | 293 | should "create an event with block" do 294 | @client_mock.stubs(:body).returns( get_mock_body("create_event.json") ) 295 | 296 | event = @calendar.create_event do |e| 297 | e.title = 'New Event' 298 | e.start_time = Time.now + (60 * 60) 299 | e.end_time = Time.now + (60 * 60 * 2) 300 | e.description = "A new event" 301 | e.location = "Joe's House" 302 | e.extended_properties = {'shared' => {'key' => 'value' }} 303 | end 304 | 305 | assert_equal event.title, 'New Event' 306 | assert_equal event.id, "fhru34kt6ikmr20knd2456l08n" 307 | end 308 | 309 | should "find properly parse all day event" do 310 | @client_mock.stubs(:body).returns( get_mock_body("find__all_day_event_by_id.json") ) 311 | event = @calendar.find_event_by_id('fhru34kt6ikmr20knd2456l10n') 312 | assert_equal event[0].id, 'fhru34kt6ikmr20knd2456l10n' 313 | assert_equal event[0].start_time, "2008-09-24T17:30:00Z" 314 | end 315 | 316 | should "find properly parse missing date event" do 317 | now = Time.now.utc 318 | Time.stubs(:now).returns(now) 319 | formatted_time = now.strftime("%FT%TZ") 320 | @client_mock.stubs(:body).returns( get_mock_body("missing_date.json") ) 321 | event = @calendar.find_event_by_id('fhru34kt6ikmr20knd2456l12n') 322 | assert_equal event[0].id, 'fhru34kt6ikmr20knd2456l12n' 323 | assert_equal event[0].start_time, formatted_time 324 | end 325 | 326 | should "create a quickadd event" do 327 | @client_mock.stubs(:body).returns( get_mock_body("create_quickadd_event.json") ) 328 | 329 | event = @calendar.create_event do |e| 330 | e.title = "movie tomorrow 23:00 at AMC Van Ness" 331 | e.quickadd = true 332 | end 333 | 334 | assert_equal event.title, "movie tomorrow 23:00 at AMC Van Ness" 335 | assert_equal event.id, 'fhru34kt6ikmr20knd2456l08n' 336 | end 337 | 338 | should "format create event with ampersand correctly" do 339 | @client_mock.stubs(:body).returns( get_mock_body("create_event.json") ) 340 | 341 | event = @calendar.create_event do |e| 342 | e.title = 'New Event with &' 343 | e.start_time = Time.now + (60 * 60) 344 | e.end_time = Time.now + (60 * 60 * 2) 345 | e.description = "A new event with &" 346 | e.location = "Joe's House & Backyard" 347 | end 348 | 349 | assert_equal event.title, 'New Event with &' 350 | assert_equal event.description, 'A new event with &' 351 | assert_equal event.location, "Joe's House & Backyard" 352 | end 353 | 354 | should "format to_s properly" do 355 | @client_mock.stubs(:body).returns( get_mock_body("query_events.json") ) 356 | event = @calendar.find_events('Test') 357 | e = event[0] 358 | assert_equal e.to_s, "Event Id '#{e.id}'\n\tStatus: #{e.status}\n\tTitle: #{e.title}\n\tStarts: #{e.start_time}\n\tEnds: #{e.end_time}\n\tLocation: #{e.location}\n\tDescription: #{e.description}\n\tColor: #{e.color_id}\n\n" 359 | end 360 | 361 | should "update an event by id" do 362 | @client_mock.stubs(:body).returns( get_mock_body("find_event_by_id.json") ) 363 | 364 | event = @calendar.find_or_create_event_by_id('t00jnpqc08rcabi6pa549ttjlk') do |e| 365 | e.title = 'New Event Update' 366 | end 367 | 368 | assert_equal event.title, 'New Event Update' 369 | end 370 | 371 | should "delete an event" do 372 | @client_mock.stubs(:body).returns( get_mock_body("create_event.json") ) 373 | 374 | event = @calendar.create_event do |e| 375 | e.title = 'Delete Me' 376 | end 377 | 378 | assert_equal event.id, 'fhru34kt6ikmr20knd2456l08n' 379 | 380 | @client_mock.stubs(:body).returns('') 381 | event.delete 382 | assert_nil event.id 383 | end 384 | 385 | should "throw exception on bad request" do 386 | @client_mock.stubs(:status).returns(400) 387 | assert_raises(HTTPRequestFailed) do 388 | @calendar.events 389 | end 390 | end 391 | 392 | should "create event when id is nil" do 393 | @client_mock.stubs(:body).returns( get_mock_body("find_event_by_id.json") ) 394 | 395 | event = @calendar.find_or_create_event_by_id(nil) do |e| 396 | e.title = 'New Event Update when id is nil' 397 | end 398 | 399 | assert_equal event.title, 'New Event Update when id is nil' 400 | end 401 | 402 | should "provide the calendar summary" do 403 | @client_mock.stubs(:body).returns( get_mock_body("events.json") ) 404 | @calendar.events 405 | assert_equal 'My Events Calendar', @calendar.summary 406 | end 407 | 408 | end # Logged on context 409 | 410 | end # Connected context 411 | 412 | context "Event instance methods" do 413 | context "#id=" do 414 | should "retain a passed-in id" do 415 | event = Event.new 416 | event.id = '8os94knodtv84h0jh4pqq4ut35' 417 | assert_equal event.id, '8os94knodtv84h0jh4pqq4ut35' 418 | end 419 | should "work with a passed-in nil id" do 420 | event = Event.new 421 | event.id = nil 422 | assert_nil event.id 423 | end 424 | should "raise an error with an invalid ID" do 425 | event = Event.new 426 | assert_raises(ArgumentError) do 427 | event.id = 'ZZZZ' # too short, invalid characters 428 | end 429 | end 430 | end 431 | 432 | context "send_notifications" do 433 | should "work when initialized with true" do 434 | event = Event.new(:send_notifications => true) 435 | assert event.send_notifications? 436 | end 437 | 438 | should "work when initialized with false" do 439 | event = Event.new(:send_notifications => false) 440 | refute event.send_notifications? 441 | end 442 | 443 | should "be dynamic" do 444 | event = Event.new 445 | event.send_notifications = false 446 | refute event.send_notifications? 447 | event.send_notifications = true 448 | assert event.send_notifications? 449 | end 450 | end 451 | 452 | context "#all_day?" do 453 | context "when the event is marked as All Day in google calendar" do 454 | should "be true" do 455 | @event = Event.new(:start_time => "2012-03-31", :end_time => "2012-04-01") 456 | assert @event.all_day? 457 | end 458 | end 459 | context "when the event is marked as All Day in google calendar and have more than one day" do 460 | should "be true" do 461 | @event = Event.new(:start_time => "2012-03-31", :end_time => "2012-04-03", :all_day => "2012-03-31") 462 | assert @event.all_day? 463 | end 464 | end 465 | context "when the event is not marked as All Day in google calendar and has duration of one whole day" do 466 | should "be false" do 467 | @event = Event.new(:start_time => "2012-03-27T10:00:00.000-07:00", :end_time => "2012-03-28T10:00:00.000-07:00") 468 | assert !@event.all_day? 469 | end 470 | end 471 | context "when the event is not an all-day event" do 472 | should "be false" do 473 | @event = Event.new(:start_time => "2012-03-27T10:00:00.000-07:00", :end_time => "2012-03-27T10:30:00.000-07:00") 474 | assert !@event.all_day? 475 | end 476 | end 477 | end 478 | 479 | context "#all_day=" do 480 | context "sets the start and end time to the appropriate values for an all day event on that day" do 481 | should "set the start time" do 482 | @event = Event.new :all_day => Time.parse("2012-05-02 12:24") 483 | assert_equal @event.start_time, "2012-05-02" 484 | end 485 | should "set the end time" do 486 | @event = Event.new :all_day => Time.parse("2012-05-02 12:24") 487 | assert_equal @event.end_time, "2012-05-03" 488 | end 489 | should "be able to handle strings" do 490 | @event = Event.new :all_day => "2012-05-02 12:24" 491 | assert_equal @event.start_time, "2012-05-02" 492 | assert_equal @event.end_time, "2012-05-03" 493 | end 494 | end 495 | end 496 | 497 | context "#creator_name" do 498 | should "include name" do 499 | event = Event.new :creator => {'displayName' => 'Someone', 'email' => 'someone@example.com'} 500 | assert_equal 'Someone', event.creator_name 501 | end 502 | end 503 | 504 | context "transparency" do 505 | should "be opaque when nil" do 506 | @event = Event.new 507 | assert @event.opaque? 508 | end 509 | 510 | should "be opaque when transparency = opaque" do 511 | @event = Event.new(:transparency => 'opaque') 512 | assert @event.opaque? 513 | end 514 | 515 | should "be opaque?" do 516 | @event = Event.new(:transparency => false) 517 | assert @event.opaque? 518 | end 519 | 520 | should "be transparent" do 521 | @event = Event.new(:transparency => true) 522 | assert @event.transparent? 523 | end 524 | 525 | should "be transparent when transparency = transparent" do 526 | @event = Event.new(:transparency => 'transparent') 527 | assert @event.transparent? 528 | end 529 | end 530 | 531 | context "event json" do 532 | should "include ID key when ID specified" do 533 | @event = Event.new(id: "nfej9pqigzneknf8llso0iehlv") 534 | assert_equal JSON.parse(@event.to_json)["id"], "nfej9pqigzneknf8llso0iehlv" 535 | end 536 | 537 | should "be correct format without ID specified" do 538 | now = Time.now 539 | @event = Event.new 540 | @event.start_time = now 541 | @event.end_time = now + (60 * 60) 542 | @event.title = "Go Swimming" 543 | @event.description = "The polar bear plunge" 544 | @event.location = "In the arctic ocean" 545 | @event.transparency = "opaque" 546 | @event.reminders = { 'useDefault' => false, 'overrides' => ['minutes' => 10, 'method' => "popup"]} 547 | @event.attendees = [ 548 | {'email' => 'some.a.one@gmail.com', 'displayName' => 'Some A One', 'responseStatus' => 'tentative'}, 549 | {'email' => 'some.b.one@gmail.com', 'displayName' => 'Some B One', 'responseStatus' => 'tentative'} 550 | ] 551 | @event.extended_properties = { 'shared' => { 'key' => 'value' }} 552 | @event.guests_can_invite_others = false 553 | @event.guests_can_see_other_guests = false 554 | 555 | expected_structure = { 556 | "summary" => "Go Swimming", 557 | "visibility"=>"default", 558 | "transparency"=>"opaque", 559 | "description" => "The polar bear plunge", 560 | "location" => "In the arctic ocean", 561 | "start" => {"dateTime" => "#{@event.start_time}"}, 562 | "end" => {"dateTime" => "#{@event.end_time}"}, 563 | "attendees" => [ 564 | {"displayName" => "Some A One", "email" => "some.a.one@gmail.com", "responseStatus" => "tentative"}, 565 | {"displayName" => "Some B One", "email" => "some.b.one@gmail.com", "responseStatus" => "tentative"} 566 | ], 567 | "reminders" => {"useDefault" => false, "overrides" => [{"method" => "popup", "minutes" => 10}]}, 568 | "extendedProperties" => {"shared" => {'key' => 'value'}}, 569 | "guestsCanInviteOthers" => false, 570 | "guestsCanSeeOtherGuests" => false 571 | } 572 | assert_equal JSON.parse(@event.to_json), expected_structure 573 | end 574 | 575 | should "return date instead of dateTime for all-day event" do 576 | @event = Event.new(all_day: "2016-10-15") 577 | json = JSON.parse(@event.to_json) 578 | assert_equal json['start']['date'], "2016-10-15" 579 | assert_equal json['end']['date'], "2016-10-16" 580 | end 581 | end 582 | 583 | context "reminders" do 584 | context "reminders hash" do 585 | should "set reminder time" do 586 | @event = Event.new(:reminders => { 'useDefault' => false, 'overrides' => ['minutes' => 6, 'method' => "popup"]}) 587 | assert_equal @event.reminders['overrides'].first['minutes'], 6 588 | end 589 | 590 | should "use different time scales" do 591 | @event = Event.new(:reminders => { 'useDefault' => false, 'overrides' => ['hours' => 6, 'method' => "popup"]}) 592 | assert_equal @event.reminders['overrides'].first['hours'], 6 593 | end 594 | 595 | should "set reminder method" do 596 | @event = Event.new(:reminders => { 'useDefault' => false, 'overrides' => ['minutes' => 6, 'method' => "sms"]}) 597 | assert_equal @event.reminders['overrides'].first['minutes'], 6 598 | assert_equal @event.reminders['overrides'].first['method'], 'sms' 599 | end 600 | end 601 | end 602 | 603 | context "at recurring events" do 604 | should "create json in correct format" do 605 | now = Time.now 606 | @event = Event.new 607 | @event.start_time = now 608 | @event.end_time = now + (60 * 60) 609 | @event.title = "Go Swimming" 610 | @event.description = "The polar bear plunge" 611 | @event.location = "In the arctic ocean" 612 | @event.transparency = "opaque" 613 | @event.reminders = { 'useDefault' => false, 'overrides' => ['minutes' => 10, 'method' => "popup"]} 614 | @event.attendees = [ 615 | {'email' => 'some.a.one@gmail.com', 'displayName' => 'Some A One', 'responseStatus' => 'tentative'}, 616 | {'email' => 'some.b.one@gmail.com', 'displayName' => 'Some B One', 'responseStatus' => 'tentative'} 617 | ] 618 | @event.recurrence = {freq: "monthly", count: "5", interval: "2"} 619 | @event.extended_properties = {'shared' => {'key' => 'value'}} 620 | @event.guests_can_invite_others = false 621 | @event.guests_can_see_other_guests = false 622 | require 'timezone_parser' 623 | expected_structure = { 624 | "summary" => "Go Swimming", 625 | "visibility"=>"default", 626 | "transparency"=>"opaque", 627 | "description" => "The polar bear plunge", 628 | "location" => "In the arctic ocean", 629 | "start" => {"dateTime" => "#{@event.start_time}", "timeZone" => "#{TimezoneParser::getTimezones(Time.now.getlocal.zone).last}"}, 630 | "end" => {"dateTime" => "#{@event.end_time}", "timeZone" => "#{TimezoneParser::getTimezones(Time.now.getlocal.zone).last}"}, 631 | "recurrence" => ["RRULE:FREQ=MONTHLY;COUNT=5;INTERVAL=2"], 632 | "attendees" => [ 633 | {"displayName" => "Some A One", "email" => "some.a.one@gmail.com", "responseStatus" => "tentative"}, 634 | {"displayName" => "Some B One", "email" => "some.b.one@gmail.com", "responseStatus" => "tentative"} 635 | ], 636 | "reminders" => {"useDefault" => false, "overrides" => [{"method" => "popup", "minutes"=>10}]}, 637 | "extendedProperties" => {"shared" => {'key' => 'value'}}, 638 | "guestsCanInviteOthers" => false, 639 | "guestsCanSeeOtherGuests" => false 640 | } 641 | assert_equal JSON.parse(@event.to_json), expected_structure 642 | end 643 | 644 | should "parse recurrence rule strings corectly" do 645 | rrule = ["RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=-1MO"] 646 | correct_hash = {"freq" => "monthly", "interval" => "2", "byday" => "-1mo"} 647 | assert_equal correct_hash.inspect, Event.parse_recurrence_rule(rrule).inspect 648 | 649 | rrule = ["RRULE:FREQ=MONTHLY;UNTIL=20170629T080000Z;INTERVAL=6"] 650 | correct_hash = {"freq" => "monthly", "until" => "20170629t080000z", "interval" => "6"} 651 | assert_equal correct_hash.inspect, Event.parse_recurrence_rule(rrule).inspect 652 | 653 | rrule = ["RRULE:FREQ=WEEKLY;BYDAY=MO,TH"] 654 | correct_hash = {"freq" => "weekly", "byday" => "mo,th"} 655 | assert_equal correct_hash.inspect, Event.parse_recurrence_rule(rrule).inspect 656 | end 657 | end 658 | end 659 | 660 | context "a calendar list" do 661 | 662 | setup do 663 | @client_mock = setup_mock_client 664 | 665 | @client_id = "671053090364-ntifn8rauvhib9h3vnsegi6dhfglk9ue.apps.googleusercontent.com" 666 | @client_secret = "roBgdbfEmJwPgrgi2mRbbO-f" 667 | @refresh_token = "1/eiqBWx8aj-BsdhwvlzDMFOUN1IN_HyThvYTujyksO4c" 668 | @redirect_url = "https://mytesturl.com/" 669 | 670 | @calendar_list = Google::CalendarList.new( 671 | :client_id => @client_id, 672 | :client_secret => @client_secret, 673 | :redirect_url => @redirect_url, 674 | :refresh_token => @refresh_token 675 | ) 676 | 677 | @client_mock.stubs(:body).returns(get_mock_body("find_calendar_list.json")) 678 | end 679 | 680 | should "find all calendars" do 681 | entries = @calendar_list.fetch_entries 682 | assert_equal entries.length, 3 683 | assert_equal entries.map(&:class).uniq, [CalendarListEntry] 684 | assert_equal entries.map(&:id), ["initech.com_ed493d0a9b46ea46c3a0d48611ce@resource.calendar.google.com", "initech.com_db18a4e59c230a5cc5d2b069a30f@resource.calendar.google.com", "bob@initech.com"] 685 | end 686 | 687 | should "set the calendar list entry parameters" do 688 | entry = @calendar_list.fetch_entries.find {|list_entry| list_entry.id == "bob@initech.com" } 689 | 690 | assert_equal entry.summary, "Bob's Calendar" 691 | assert_equal entry.time_zone, "Europe/London" 692 | assert_equal entry.access_role, "owner" 693 | assert_equal entry.primary?, true 694 | end 695 | 696 | should "accept a connection to re-use" do 697 | reusable_connection = mock('Google::Connection') 698 | reusable_connection.expects(:send).returns(@client_mock) 699 | 700 | calendar_list = CalendarList.new({}, reusable_connection) 701 | calendar_list.fetch_entries 702 | end 703 | 704 | should "return entries which can create calendars" do 705 | entry = @calendar_list.fetch_entries.first 706 | calendar = entry.to_calendar 707 | 708 | assert_equal calendar.class, Calendar 709 | assert_equal calendar.id, entry.id 710 | assert_equal calendar.connection, @calendar_list.connection 711 | end 712 | 713 | end 714 | 715 | context "a freebusy query" do 716 | setup do 717 | @client_mock = setup_mock_client 718 | 719 | @client_id = "671053090364-ntifn8rauvhib9h3vnsegi6dhfglk9ue.apps.googleusercontent.com" 720 | @client_secret = "roBgdbfEmJwPgrgi2mRbbO-f" 721 | @refresh_token = "1/eiqBWx8aj-BsdhwvlzDMFOUN1IN_HyThvYTujyksO4c" 722 | @redirect_url = "https://mytesturl.com/" 723 | 724 | @freebusy = Google::Freebusy.new( 725 | :client_id => @client_id, 726 | :client_secret => @client_secret, 727 | :redirect_url => @redirect_url, 728 | :refresh_token => @refresh_token 729 | ) 730 | 731 | @client_mock.stubs(:body).returns(get_mock_body("freebusy_query.json")) 732 | 733 | @calendar_ids = ['busy-calendar-id', 'not-busy-calendar-id'] 734 | @start_time = Time.new(2015, 3, 6, 0, 0, 0) 735 | @end_time = Time.new(2015, 3, 6, 23, 59, 59) 736 | end 737 | 738 | should "return a hash with keys of the supplied calendar ids" do 739 | assert_equal ['busy-calendar-id', 'not-busy-calendar-id'], @freebusy.query(@calendar_ids, @start_time, @end_time).keys 740 | end 741 | 742 | should "returns the busy times for each calendar supplied" do 743 | freebusy_result = @freebusy.query(@calendar_ids, @start_time, @end_time) 744 | 745 | assert_equal ({'start' => DateTime.new(2015, 3, 6, 10, 0, 0, 0), 'end' => DateTime.new(2015, 3, 6, 11, 0, 0, 0) }), freebusy_result['busy-calendar-id'].first 746 | assert_equal ({'start' => DateTime.new(2015, 3, 6, 11, 30, 0, 0), 'end' => DateTime.new(2015, 3, 6, 11, 30, 0, 0) }), freebusy_result['busy-calendar-id'].last 747 | assert_equal [], freebusy_result['not-busy-calendar-id'] 748 | end 749 | end 750 | 751 | protected 752 | 753 | def get_mock_body(name) 754 | File.open(@@mock_path + '/' + name).read 755 | end 756 | 757 | def setup_mock_client 758 | client = mock('Faraday::Response') 759 | client.stubs(:finish).returns('') 760 | client.stubs(:status).returns(200) 761 | client.stubs(:headers).returns({'Content-type' => 'application/json; charset=utf-8'}) 762 | client.stubs(:body).returns(get_mock_body('successful_login.json')) 763 | Faraday::Response.stubs(:new).returns(client) 764 | client 765 | end 766 | 767 | end 768 | --------------------------------------------------------------------------------