calendar-assistant 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,9 @@
1
+ #require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "concourse"
4
+
5
+ Concourse.new("calendar-assistant").create_tasks!
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :default => :spec
@@ -0,0 +1,12 @@
1
+ #! /usr/bin/env ruby
2
+
3
+ require "calendar_assistant"
4
+
5
+ Rainbow.enabled = true
6
+
7
+ begin
8
+ CalendarAssistant::CLI.start ARGV
9
+ rescue Google::Apis::AuthorizationError, CalendarAssistant::BaseException => e
10
+ printf "ERROR: %s\n", e
11
+ exit 1
12
+ end
@@ -0,0 +1,202 @@
1
+ # coding: utf-8
2
+ require "google/apis/calendar_v3"
3
+ require "json"
4
+ require "yaml"
5
+ require "business_time"
6
+ require "rainbow"
7
+ require "set"
8
+
9
+ require "calendar_assistant/version"
10
+
11
+ class CalendarAssistant
12
+ GCal = Google::Apis::CalendarV3
13
+
14
+ class BaseException < RuntimeError ; end
15
+
16
+ EMOJI_WORLDMAP = "🗺" # U+1F5FA WORLD MAP
17
+ EMOJI_PLANE = "🛪" # U+1F6EA NORTHEAST-POINTING AIRPLANE
18
+ EMOJI_1_1 = "👫" # MAN AND WOMAN HOLDING HANDS
19
+
20
+ DEFAULT_CALENDAR_ID = "primary"
21
+
22
+ attr_reader :service, :calendar, :config
23
+
24
+ def self.authorize profile_name
25
+ config = CalendarAssistant::Config.new
26
+ Authorizer.new(profile_name, config.token_store).authorize
27
+ end
28
+
29
+ def self.date_range_cast time_range
30
+ time_range.first.to_date..(time_range.last + 1.day).to_date
31
+ end
32
+
33
+ def initialize config=CalendarAssistant::Config.new
34
+ @config = config
35
+ @service = Authorizer.new(config.profile_name, config.token_store).service
36
+ @calendar = service.get_calendar DEFAULT_CALENDAR_ID
37
+ end
38
+
39
+ def find_events time_range
40
+ events = service.list_events(DEFAULT_CALENDAR_ID,
41
+ time_min: time_range.first.iso8601,
42
+ time_max: time_range.last.iso8601,
43
+ order_by: "startTime",
44
+ single_events: true,
45
+ max_results: 2000,
46
+ )
47
+ if events.nil? || events.items.nil?
48
+ return []
49
+ end
50
+ events.items
51
+ end
52
+
53
+ def availability time_range
54
+ length = ChronicDuration.parse(config.setting(Config::Keys::Settings::MEETING_LENGTH))
55
+
56
+ start_of_day = Chronic.parse(config.setting(Config::Keys::Settings::START_OF_DAY))
57
+ start_of_day = start_of_day - start_of_day.beginning_of_day
58
+
59
+ end_of_day = Chronic.parse(config.setting(Config::Keys::Settings::END_OF_DAY))
60
+ end_of_day = end_of_day - end_of_day.beginning_of_day
61
+
62
+ events = find_events time_range
63
+ date_range = time_range.first.to_date .. time_range.last.to_date
64
+
65
+ # find relevant events and map them into dates
66
+ dates_events = date_range.inject({}) { |de, date| de[date] = [] ; de }
67
+ events.each do |event|
68
+ if event.accepted?
69
+ event_date = event.start.to_date!
70
+ dates_events[event_date] ||= []
71
+ dates_events[event_date] << event
72
+ end
73
+ dates_events
74
+ end
75
+
76
+ # iterate over the days finding free chunks of time
77
+ avail_time = date_range.inject({}) do |avail_time, date|
78
+ avail_time[date] ||= []
79
+ date_events = dates_events[date]
80
+
81
+ start_time = date.to_time + start_of_day
82
+ end_time = date.to_time + end_of_day
83
+
84
+ date_events.each do |e|
85
+ if (e.start.date_time.to_time - start_time) >= length
86
+ avail_time[date] << CalendarAssistant.available_block(start_time.to_datetime, e.start.date_time)
87
+ end
88
+ start_time = e.end.date_time.to_time
89
+ break if start_time >= end_time
90
+ end
91
+
92
+ if end_time - start_time >= length
93
+ avail_time[date] << CalendarAssistant.available_block(start_time.to_datetime, end_time.to_datetime)
94
+ end
95
+
96
+ avail_time
97
+ end
98
+
99
+ avail_time
100
+ end
101
+
102
+ def find_location_events time_range
103
+ find_events(time_range).select { |e| e.location_event? }
104
+ end
105
+
106
+ def create_location_event time_range, location
107
+ # find pre-existing events that overlap
108
+ existing_events = find_location_events time_range
109
+
110
+ # augment event end date appropriately
111
+ range = CalendarAssistant.date_range_cast time_range
112
+
113
+ deleted_events = []
114
+ modified_events = []
115
+
116
+ event = GCal::Event.new start: GCal::EventDateTime.new(date: range.first.iso8601),
117
+ end: GCal::EventDateTime.new(date: range.last.iso8601),
118
+ summary: "#{EMOJI_WORLDMAP} #{location}",
119
+ transparency: GCal::Event::Transparency::TRANSPARENT
120
+
121
+ event = service.insert_event DEFAULT_CALENDAR_ID, event
122
+
123
+ existing_events.each do |existing_event|
124
+ if existing_event.start.date >= event.start.date && existing_event.end.date <= event.end.date
125
+ service.delete_event DEFAULT_CALENDAR_ID, existing_event.id
126
+ deleted_events << existing_event
127
+ elsif existing_event.start.date <= event.end.date && existing_event.end.date > event.end.date
128
+ existing_event.update! start: GCal::EventDateTime.new(date: range.last)
129
+ service.update_event DEFAULT_CALENDAR_ID, existing_event.id, existing_event
130
+ modified_events << existing_event
131
+ elsif existing_event.start.date < event.start.date && existing_event.end.date >= event.start.date
132
+ existing_event.update! end: GCal::EventDateTime.new(date: range.first)
133
+ service.update_event DEFAULT_CALENDAR_ID, existing_event.id, existing_event
134
+ modified_events << existing_event
135
+ end
136
+ end
137
+
138
+ response = {created: [event]}
139
+ response[:deleted] = deleted_events unless deleted_events.empty?
140
+ response[:modified] = modified_events unless modified_events.empty?
141
+ response
142
+ end
143
+
144
+ def event_description event
145
+ s = sprintf("%-25.25s", event_date_description(event))
146
+
147
+ date_ansi_codes = []
148
+ date_ansi_codes << :bright if event.current?
149
+ date_ansi_codes << :faint if event.past?
150
+ s = date_ansi_codes.inject(Rainbow(s)) { |text, ansi| text.send ansi }
151
+
152
+ s += Rainbow(sprintf(" | %s", event.view_summary)).bold
153
+
154
+ attributes = []
155
+ unless event.private?
156
+ attributes << "recurring" if event.recurring_event_id
157
+ attributes << "not-busy" unless event.busy?
158
+ attributes << "self" if event.human_attendees.nil? && event.visibility != "private"
159
+ attributes << "1:1" if event.one_on_one?
160
+ end
161
+ s += Rainbow(sprintf(" (%s)", attributes.to_a.sort.join(", "))).italic unless attributes.empty?
162
+
163
+ s = Rainbow(Rainbow.uncolor(s)).faint.strike if event.declined?
164
+
165
+ s
166
+ end
167
+
168
+ def event_date_description event
169
+ if event.all_day?
170
+ start_date = event.start.to_date
171
+ end_date = event.end.to_date
172
+ if (end_date - start_date) <= 1
173
+ event.start.to_s
174
+ else
175
+ sprintf("%s - %s", start_date, end_date - 1.day)
176
+ end
177
+ else
178
+ if event.start.date_time.to_date == event.end.date_time.to_date
179
+ sprintf("%s - %s", event.start.date_time.strftime("%Y-%m-%d %H:%M"), event.end.date_time.strftime("%H:%M"))
180
+ else
181
+ sprintf("%s - %s", event.start.date_time.strftime("%Y-%m-%d %H:%M"), event.end.date_time.strftime("%Y-%m-%d %H:%M"))
182
+ end
183
+ end
184
+ end
185
+
186
+ private
187
+
188
+ def self.available_block start_time, end_time
189
+ Google::Apis::CalendarV3::Event.new(
190
+ start: Google::Apis::CalendarV3::EventDateTime.new(date_time: start_time),
191
+ end: Google::Apis::CalendarV3::EventDateTime.new(date_time: end_time),
192
+ summary: "available"
193
+ )
194
+ end
195
+ end
196
+
197
+ require "calendar_assistant/config"
198
+ require "calendar_assistant/authorizer"
199
+ require "calendar_assistant/cli"
200
+ require "calendar_assistant/string_helpers"
201
+ require "calendar_assistant/event_extensions"
202
+ require "calendar_assistant/rainbow_extensions"
@@ -0,0 +1,88 @@
1
+ #
2
+ # code in this file is inspired by
3
+ #
4
+ # https://github.com/gsuitedevs/ruby-samples/blob/master/calendar/quickstart/quickstart.rb
5
+ #
6
+ # Copyright 2018 Google LLC
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # https://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ require 'googleauth'
21
+ require 'rainbow'
22
+
23
+ class CalendarAssistant
24
+ class Authorizer
25
+ class NoCredentials < CalendarAssistant::BaseException ; end
26
+ class UnauthorizedError < CalendarAssistant::BaseException ; end
27
+
28
+ OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'.freeze
29
+ APPLICATION_NAME = "Flavorjones Calendar Assistant".freeze
30
+ CREDENTIALS_PATH = 'credentials.json'.freeze
31
+ SCOPE = Google::Apis::CalendarV3::AUTH_CALENDAR
32
+
33
+ attr_reader :profile_name, :config_token_store
34
+
35
+ def initialize profile_name, config_token_store
36
+ @profile_name = profile_name
37
+ @config_token_store = config_token_store
38
+ end
39
+
40
+ def authorize
41
+ credentials || prompt_user_for_authorization
42
+ end
43
+
44
+ def service
45
+ if credentials.nil?
46
+ raise UnauthorizedError, "Not authorized. Please run `calendar-assistant authorize #{profile_name}`"
47
+ end
48
+
49
+ Google::Apis::CalendarV3::CalendarService.new.tap do |service|
50
+ service.client_options.application_name = APPLICATION_NAME
51
+ service.authorization = credentials
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def credentials
58
+ @credentials ||= authorizer.get_credentials profile_name
59
+ end
60
+
61
+ def prompt_user_for_authorization
62
+ url = authorizer.get_authorization_url(base_url: OOB_URI)
63
+
64
+ puts Rainbow("Please open this URL in your browser:").bold
65
+ puts
66
+ puts " " + url
67
+ puts
68
+
69
+ puts Rainbow("Then authorize '#{APPLICATION_NAME}' to manage your calendar and copy/paste the resulting code here:").bold
70
+ puts
71
+ print "> "
72
+ code = STDIN.gets
73
+
74
+ authorizer.get_and_store_credentials_from_code(user_id: profile_name, code: code, base_url: OOB_URI)
75
+ end
76
+
77
+ def authorizer
78
+ @authorizer ||= begin
79
+ if ! File.exists?(CREDENTIALS_PATH)
80
+ raise NoCredentials, "No credentials found. Please run `calendar-assistant help authorize` for help"
81
+ end
82
+
83
+ client_id = Google::Auth::ClientId.from_file(CREDENTIALS_PATH)
84
+ Google::Auth::UserAuthorizer.new(client_id, SCOPE, config_token_store)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,162 @@
1
+ require "thor"
2
+ require "chronic"
3
+ require "chronic_duration"
4
+ require "launchy"
5
+
6
+ require "calendar_assistant/cli_helpers"
7
+
8
+ class CalendarAssistant
9
+ class CLI < Thor
10
+ default_config = CalendarAssistant::Config.new options: options # used in option descriptions
11
+
12
+ # it's unfortunate that thor does not support this usage of help args
13
+ class_option :help,
14
+ type: :boolean,
15
+ aliases: ["-h", "-?"]
16
+
17
+ # note that these options are passed straight through to CLIHelpers.print_events
18
+ class_option :profile,
19
+ type: :string,
20
+ desc: "the profile you'd like to use (if different from default)",
21
+ aliases: ["-p"]
22
+ class_option :debug,
23
+ type: :boolean,
24
+ desc: "how dare you suggest there are bugs"
25
+
26
+
27
+ desc "authorize PROFILE_NAME",
28
+ "create (or validate) a profile named NAME with calendar access"
29
+ long_desc <<~EOD
30
+ Create and authorize a named profile (e.g., "work", "home",
31
+ "flastname@company.tld") to access your calendar.
32
+
33
+ When setting up a profile, you'll be asked to visit a URL to
34
+ authenticate, grant authorization, and generate and persist an
35
+ access token.
36
+
37
+ In order for this to work, you'll need to follow the
38
+ instructions at this URL first:
39
+
40
+ > https://developers.google.com/calendar/quickstart/ruby
41
+
42
+ Namely, the prerequisites are:
43
+ \x5 1. Turn on the Google API for your account
44
+ \x5 2. Create a new Google API Project
45
+ \x5 3. Download the configuration file for the Project, and name it as `credentials.json`
46
+ EOD
47
+ def authorize profile_name
48
+ return if handle_help_args
49
+ CalendarAssistant.authorize profile_name
50
+ puts "\nYou're authorized!\n\n"
51
+ end
52
+
53
+
54
+ desc "show [DATE | DATERANGE | TIMERANGE]",
55
+ "Show your events for a date or range of dates (default 'today')"
56
+ option :commitments,
57
+ type: :boolean,
58
+ desc: "only show events that you've accepted with another person",
59
+ aliases: ["-c"]
60
+ def show datespec="today"
61
+ return if handle_help_args
62
+ config = CalendarAssistant::Config.new options: options
63
+ ca = CalendarAssistant.new config
64
+ events = ca.find_events CLIHelpers.parse_datespec(datespec)
65
+ CLIHelpers::Out.new.print_events ca, events, options
66
+ end
67
+
68
+
69
+ desc "join [TIME]",
70
+ "Open the URL for a video call attached to your meeting at time TIME (default 'now')"
71
+ option :join,
72
+ type: :boolean, default: true,
73
+ desc: "launch a browser to join the video call URL"
74
+ def join timespec="now"
75
+ return if handle_help_args
76
+ config = CalendarAssistant::Config.new options: options
77
+ ca = CalendarAssistant.new config
78
+ event, url = CLIHelpers.find_av_uri ca, timespec
79
+ if event
80
+ CLIHelpers::Out.new.print_events ca, event, options
81
+ CLIHelpers::Out.new.puts url
82
+ if options[:join]
83
+ CLIHelpers::Out.new.launch url
84
+ end
85
+ else
86
+ CLIHelpers::Out.new.puts "Could not find a meeting '#{timespec}' with a video call to join."
87
+ end
88
+ end
89
+
90
+
91
+ desc "location [DATE | DATERANGE]",
92
+ "Show your location for a date or range of dates (default 'today')"
93
+ def location datespec="today"
94
+ return if handle_help_args
95
+ config = CalendarAssistant::Config.new options: options
96
+ ca = CalendarAssistant.new config
97
+ events = ca.find_location_events CLIHelpers.parse_datespec(datespec)
98
+ CLIHelpers::Out.new.print_events ca, events, options
99
+ end
100
+
101
+
102
+ desc "location-set LOCATION [DATE | DATERANGE]",
103
+ "Set your location to LOCATION for a date or range of dates (default 'today')"
104
+ def location_set location, datespec="today"
105
+ return if handle_help_args
106
+ config = CalendarAssistant::Config.new options: options
107
+ ca = CalendarAssistant.new config
108
+ events = ca.create_location_event CLIHelpers.parse_datespec(datespec), location
109
+ CLIHelpers::Out.new.print_events ca, events, options
110
+ end
111
+
112
+ desc "availability [DATE | DATERANGE | TIMERANGE]",
113
+ "Show your availability for a date or range of dates (default 'today')"
114
+ option CalendarAssistant::Config::Keys::Settings::MEETING_LENGTH,
115
+ type: :string,
116
+ banner: "LENGTH",
117
+ desc: sprintf("[default %s] find chunks of available time at least as long as LENGTH (which is a ChronicDuration string like '30m' or '2h')",
118
+ default_config.setting(CalendarAssistant::Config::Keys::Settings::MEETING_LENGTH)),
119
+ aliases: ["-l"]
120
+ option CalendarAssistant::Config::Keys::Settings::START_OF_DAY,
121
+ type: :string,
122
+ banner: "TIME",
123
+ desc: sprintf("[default %s] find chunks of available time after TIME (which is a Chronic string like '9am' or '14:30')",
124
+ default_config.setting(CalendarAssistant::Config::Keys::Settings::START_OF_DAY)),
125
+ aliases: ["-s"]
126
+ option CalendarAssistant::Config::Keys::Settings::END_OF_DAY,
127
+ type: :string,
128
+ banner: "TIME",
129
+ desc: sprintf("[default %s] find chunks of available time before TIME (which is a Chronic string like '9am' or '14:30')",
130
+ default_config.setting(CalendarAssistant::Config::Keys::Settings::END_OF_DAY)),
131
+ aliases: ["-e"]
132
+ def availability datespec="today"
133
+ return if handle_help_args
134
+ config = CalendarAssistant::Config.new options: options
135
+ ca = CalendarAssistant.new config
136
+ events = ca.availability CLIHelpers.parse_datespec(datespec)
137
+ CLIHelpers::Out.new.print_available_blocks ca, events, options
138
+ end
139
+
140
+ desc "config",
141
+ "Dump your configuration parameters (merge of defaults and overrides from #{CalendarAssistant::Config::CONFIG_FILE_PATH})"
142
+ def config
143
+ return if handle_help_args
144
+ config = CalendarAssistant::Config.new
145
+ settings = {}
146
+ setting_names = CalendarAssistant::Config::Keys::Settings.constants.map { |k| CalendarAssistant::Config::Keys::Settings.const_get k }
147
+ setting_names.each do |key|
148
+ settings[key] = config.setting key
149
+ end
150
+ puts TOML::Generator.new({CalendarAssistant::Config::Keys::SETTINGS => settings}).body
151
+ end
152
+
153
+ private
154
+
155
+ def handle_help_args
156
+ if options[:help]
157
+ help(current_command_chain.first)
158
+ return true
159
+ end
160
+ end
161
+ end
162
+ end