calendar-assistant 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/LICENSE +202 -0
- data/NOTICE +13 -0
- data/README.md +410 -0
- data/Rakefile +9 -0
- data/bin/calendar-assistant +12 -0
- data/lib/calendar_assistant.rb +202 -0
- data/lib/calendar_assistant/authorizer.rb +88 -0
- data/lib/calendar_assistant/cli.rb +162 -0
- data/lib/calendar_assistant/cli_helpers.rb +143 -0
- data/lib/calendar_assistant/config.rb +153 -0
- data/lib/calendar_assistant/config/token_store.rb +25 -0
- data/lib/calendar_assistant/event_extensions.rb +173 -0
- data/lib/calendar_assistant/rainbow_extensions.rb +22 -0
- data/lib/calendar_assistant/string_helpers.rb +13 -0
- data/lib/calendar_assistant/version.rb +3 -0
- metadata +285 -0
data/Rakefile
ADDED
@@ -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
|