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.
@@ -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