calendar-assistant 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|