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
@@ -0,0 +1,143 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
class CalendarAssistant
|
3
|
+
module CLIHelpers
|
4
|
+
def self.parse_datespec userspec
|
5
|
+
start_userspec, end_userspec = userspec.split(/ ?\.\.\.? ?/)
|
6
|
+
|
7
|
+
if end_userspec.nil?
|
8
|
+
time = Chronic.parse(userspec) || raise("could not parse #{userspec}")
|
9
|
+
return time.beginning_of_day..time.end_of_day
|
10
|
+
end
|
11
|
+
|
12
|
+
start_time = Chronic.parse(start_userspec) || raise("could not parse #{start_userspec}")
|
13
|
+
end_time = Chronic.parse(end_userspec) || raise("could not parse #{end_userspec}")
|
14
|
+
|
15
|
+
if start_time.to_date == end_time.to_date
|
16
|
+
start_time..end_time
|
17
|
+
else
|
18
|
+
start_time.beginning_of_day..end_time.end_of_day
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.now
|
23
|
+
GCal::Event.new start: GCal::EventDateTime.new(date_time: Time.now),
|
24
|
+
end: GCal::EventDateTime.new(date_time: Time.now),
|
25
|
+
summary: Rainbow(" now ").inverse.faint
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.find_av_uri ca, timespec
|
29
|
+
time = Chronic.parse timespec
|
30
|
+
range = time..(time+5.minutes)
|
31
|
+
events = ca.find_events range
|
32
|
+
|
33
|
+
[Google::Apis::CalendarV3::Event::Response::ACCEPTED,
|
34
|
+
Google::Apis::CalendarV3::Event::Response::TENTATIVE,
|
35
|
+
Google::Apis::CalendarV3::Event::Response::NEEDS_ACTION,
|
36
|
+
].each do |response|
|
37
|
+
events.reverse.select do |event|
|
38
|
+
event.response_status == response
|
39
|
+
end.each do |event|
|
40
|
+
return [event, event.av_uri] if event.av_uri
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
nil
|
45
|
+
end
|
46
|
+
|
47
|
+
class Out
|
48
|
+
attr_reader :io
|
49
|
+
|
50
|
+
def initialize io=STDOUT
|
51
|
+
@io = io
|
52
|
+
end
|
53
|
+
|
54
|
+
def launch url
|
55
|
+
Launchy.open url
|
56
|
+
end
|
57
|
+
|
58
|
+
def puts *args
|
59
|
+
io.puts(*args)
|
60
|
+
end
|
61
|
+
|
62
|
+
def print_now! ca, event, printed_now
|
63
|
+
return true if printed_now
|
64
|
+
return false if event.start_date != Date.today
|
65
|
+
|
66
|
+
if event.start_time > Time.now
|
67
|
+
puts ca.event_description(CLIHelpers.now)
|
68
|
+
return true
|
69
|
+
end
|
70
|
+
|
71
|
+
false
|
72
|
+
end
|
73
|
+
|
74
|
+
def print_events ca, events, options={}
|
75
|
+
unless options[:omit_title]
|
76
|
+
puts Rainbow("#{ca.calendar.id} (all times in #{ca.calendar.time_zone})\n").italic
|
77
|
+
options = options.merge(omit_title: true)
|
78
|
+
end
|
79
|
+
|
80
|
+
if events.is_a?(Hash)
|
81
|
+
events.each do |key, value|
|
82
|
+
puts Rainbow(key.to_s.capitalize + ":").bold.italic
|
83
|
+
print_events ca, value, options
|
84
|
+
end
|
85
|
+
return
|
86
|
+
end
|
87
|
+
|
88
|
+
events = Array(events)
|
89
|
+
if events.empty?
|
90
|
+
puts "No events in this time range."
|
91
|
+
return
|
92
|
+
end
|
93
|
+
|
94
|
+
display_events = events.select do |event|
|
95
|
+
! options[:commitments] || event.commitment?
|
96
|
+
end
|
97
|
+
|
98
|
+
printed_now = false
|
99
|
+
display_events.each do |event|
|
100
|
+
printed_now = print_now! ca, event, printed_now
|
101
|
+
puts ca.event_description(event)
|
102
|
+
pp event if options[:debug]
|
103
|
+
end
|
104
|
+
|
105
|
+
puts
|
106
|
+
end
|
107
|
+
|
108
|
+
def print_available_blocks ca, events, options={}
|
109
|
+
unless options[:omit_title]
|
110
|
+
puts Rainbow(sprintf("%s\n- all times in %s\n- looking for blocks at least %s long\n",
|
111
|
+
ca.calendar.id,
|
112
|
+
ca.calendar.time_zone,
|
113
|
+
ChronicDuration.output(ChronicDuration.parse(ca.config.setting(Config::Keys::Settings::MEETING_LENGTH))))
|
114
|
+
).italic
|
115
|
+
options = options.merge(omit_title: true)
|
116
|
+
end
|
117
|
+
|
118
|
+
if events.is_a?(Hash)
|
119
|
+
events.each do |key, value|
|
120
|
+
puts(sprintf(Rainbow("Availability on %s:\n").bold,
|
121
|
+
key.strftime("%A, %B %-d")))
|
122
|
+
print_available_blocks ca, value, options
|
123
|
+
puts
|
124
|
+
end
|
125
|
+
return
|
126
|
+
end
|
127
|
+
|
128
|
+
events = Array(events)
|
129
|
+
if events.empty?
|
130
|
+
puts " (No available blocks in this time range.)"
|
131
|
+
return
|
132
|
+
end
|
133
|
+
|
134
|
+
events.each do |event|
|
135
|
+
puts(sprintf(" • %s - %s",
|
136
|
+
event.start.date_time.strftime("%-l:%M%P"),
|
137
|
+
event.end.date_time.strftime("%-l:%M%P")))
|
138
|
+
pp event if options[:debug]
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
require "toml"
|
2
|
+
|
3
|
+
class CalendarAssistant
|
4
|
+
class Config
|
5
|
+
class TomlParseFailure < CalendarAssistant::BaseException ; end
|
6
|
+
class NoConfigFileToPersist < CalendarAssistant::BaseException ; end
|
7
|
+
class NoTokensAuthorized < CalendarAssistant::BaseException ; end
|
8
|
+
class AccessingHashAsScalar < CalendarAssistant::BaseException ; end
|
9
|
+
|
10
|
+
CONFIG_FILE_PATH = File.join ENV["HOME"], ".calendar-assistant"
|
11
|
+
|
12
|
+
module Keys
|
13
|
+
TOKENS = "tokens"
|
14
|
+
SETTINGS = "settings"
|
15
|
+
|
16
|
+
module Settings
|
17
|
+
PROFILE = "profile"
|
18
|
+
MEETING_LENGTH = "meeting-length"
|
19
|
+
START_OF_DAY = "start-of-day"
|
20
|
+
END_OF_DAY = "end-of-day"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
DEFAULT_SETTINGS = {
|
25
|
+
Keys::Settings::MEETING_LENGTH => "30m", # ChronicDuration
|
26
|
+
Keys::Settings::START_OF_DAY => "9am", # Chronic
|
27
|
+
Keys::Settings::END_OF_DAY => "6pm", # Chronic
|
28
|
+
}
|
29
|
+
|
30
|
+
attr_reader :config_file_path, :user_config, :options, :defaults
|
31
|
+
|
32
|
+
def initialize options: {},
|
33
|
+
config_file_path: CONFIG_FILE_PATH,
|
34
|
+
config_io: nil,
|
35
|
+
defaults: DEFAULT_SETTINGS
|
36
|
+
if config_io.nil?
|
37
|
+
@config_file_path = config_file_path
|
38
|
+
end
|
39
|
+
|
40
|
+
@user_config = if config_io
|
41
|
+
begin
|
42
|
+
TOML.load config_io.read
|
43
|
+
rescue Exception => e
|
44
|
+
raise TomlParseFailure, "could not parse IO stream: #{e}"
|
45
|
+
end
|
46
|
+
elsif File.exist? config_file_path
|
47
|
+
begin
|
48
|
+
TOML.load_file config_file_path
|
49
|
+
rescue Exception => e
|
50
|
+
raise TomlParseFailure, "could not parse #{config_file_path}: #{e}"
|
51
|
+
end
|
52
|
+
else
|
53
|
+
Hash.new
|
54
|
+
end
|
55
|
+
|
56
|
+
@defaults = defaults
|
57
|
+
@options = options
|
58
|
+
end
|
59
|
+
|
60
|
+
def profile_name
|
61
|
+
# CLI option takes precedence
|
62
|
+
return options["profile"] if options["profile"]
|
63
|
+
|
64
|
+
# then a configured preference takes precedence
|
65
|
+
default = get([Keys::SETTINGS, Keys::Settings::PROFILE])
|
66
|
+
return default if default
|
67
|
+
|
68
|
+
# finally we'll grab the first configured token and set that as the default
|
69
|
+
token_names = tokens.keys
|
70
|
+
if token_names.empty?
|
71
|
+
raise NoTokensAuthorized, "Please run `calendar-assistant help authorize` for help."
|
72
|
+
end
|
73
|
+
token_names.first.tap do |new_default|
|
74
|
+
Config.set_in_hash user_config, [Keys::SETTINGS, Keys::Settings::PROFILE], new_default
|
75
|
+
persist!
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def get keypath
|
80
|
+
rval = Config.find_in_hash(user_config, keypath)
|
81
|
+
|
82
|
+
if rval.is_a?(Hash)
|
83
|
+
raise AccessingHashAsScalar, "keypath #{keypath} is not a scalar"
|
84
|
+
end
|
85
|
+
|
86
|
+
rval
|
87
|
+
end
|
88
|
+
|
89
|
+
def set keypath, value
|
90
|
+
Config.set_in_hash user_config, keypath, value
|
91
|
+
end
|
92
|
+
|
93
|
+
def setting setting_name
|
94
|
+
Config.find_in_hash(options, setting_name) ||
|
95
|
+
Config.find_in_hash(user_config, [Keys::SETTINGS, setting_name]) ||
|
96
|
+
Config.find_in_hash(defaults, setting_name)
|
97
|
+
end
|
98
|
+
|
99
|
+
def tokens
|
100
|
+
Config.find_in_hash(user_config, Keys::TOKENS) ||
|
101
|
+
Config.set_in_hash(user_config, Keys::TOKENS, {})
|
102
|
+
end
|
103
|
+
|
104
|
+
def token_store
|
105
|
+
CalendarAssistant::Config::TokenStore.new self
|
106
|
+
end
|
107
|
+
|
108
|
+
def persist!
|
109
|
+
if config_file_path.nil?
|
110
|
+
raise NoConfigFileToPersist, "Cannot persist config when initialized with an IO"
|
111
|
+
end
|
112
|
+
|
113
|
+
content = TOML::Generator.new(user_config).body
|
114
|
+
|
115
|
+
File.open(config_file_path, "w") do |f|
|
116
|
+
f.write content
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def self.find_in_hash hash, keypath
|
123
|
+
current_val = hash
|
124
|
+
keypath = keypath.split(".") unless keypath.is_a?(Array)
|
125
|
+
|
126
|
+
keypath.each do |key|
|
127
|
+
if current_val.has_key?(key)
|
128
|
+
current_val = current_val[key]
|
129
|
+
else
|
130
|
+
current_val = nil
|
131
|
+
break
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
current_val
|
136
|
+
end
|
137
|
+
|
138
|
+
def self.set_in_hash hash, keypath, new_value
|
139
|
+
current_hash = hash
|
140
|
+
keypath = keypath.split(".") unless keypath.is_a?(Array)
|
141
|
+
*path_parts, key = *keypath
|
142
|
+
|
143
|
+
path_parts.each do |path_part|
|
144
|
+
current_hash[path_part] ||= {}
|
145
|
+
current_hash = current_hash[path_part]
|
146
|
+
end
|
147
|
+
|
148
|
+
current_hash[key] = new_value
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
require "calendar_assistant/config/token_store"
|
@@ -0,0 +1,25 @@
|
|
1
|
+
class CalendarAssistant
|
2
|
+
class Config
|
3
|
+
class TokenStore
|
4
|
+
attr_reader :config
|
5
|
+
|
6
|
+
def initialize config
|
7
|
+
@config = config
|
8
|
+
end
|
9
|
+
|
10
|
+
def delete id
|
11
|
+
config.tokens.delete(id)
|
12
|
+
config.persist!
|
13
|
+
end
|
14
|
+
|
15
|
+
def load id
|
16
|
+
config.tokens[id]
|
17
|
+
end
|
18
|
+
|
19
|
+
def store id, token
|
20
|
+
config.tokens[id] = token
|
21
|
+
config.persist!
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
#
|
2
|
+
# this file extends the Google::Event class found in the "google_calendar" rubygem
|
3
|
+
#
|
4
|
+
|
5
|
+
require "google/apis/calendar_v3"
|
6
|
+
require "time"
|
7
|
+
|
8
|
+
class Google::Apis::CalendarV3::Event
|
9
|
+
module RealResponse
|
10
|
+
DECLINED = "declined"
|
11
|
+
ACCEPTED = "accepted"
|
12
|
+
NEEDS_ACTION = "needsAction"
|
13
|
+
TENTATIVE = "tentative"
|
14
|
+
end
|
15
|
+
|
16
|
+
module Response
|
17
|
+
include RealResponse
|
18
|
+
SELF = "self" # not part of Google's API, but useful to represent meetings-for-myself
|
19
|
+
end
|
20
|
+
|
21
|
+
module Transparency
|
22
|
+
TRANSPARENT = "transparent"
|
23
|
+
OPAQUE = "opaque"
|
24
|
+
end
|
25
|
+
|
26
|
+
module Visibility
|
27
|
+
DEFAULT = "default"
|
28
|
+
PUBLIC = "public"
|
29
|
+
PRIVATE = "private"
|
30
|
+
end
|
31
|
+
|
32
|
+
LOCATION_EVENT_REGEX = /^#{CalendarAssistant::EMOJI_WORLDMAP}/
|
33
|
+
|
34
|
+
def update **args
|
35
|
+
# this should be in the google API classes, IMHO
|
36
|
+
update!(**args)
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def location_event?
|
41
|
+
!! (summary =~ LOCATION_EVENT_REGEX)
|
42
|
+
end
|
43
|
+
|
44
|
+
def all_day?
|
45
|
+
!! @start.to_date
|
46
|
+
end
|
47
|
+
|
48
|
+
def past?
|
49
|
+
if all_day?
|
50
|
+
Date.today >= self.end.to_date
|
51
|
+
else
|
52
|
+
Time.now >= self.end.date_time
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def current?
|
57
|
+
! (past? || future?)
|
58
|
+
end
|
59
|
+
|
60
|
+
def future?
|
61
|
+
if all_day?
|
62
|
+
self.start.to_date > Date.today
|
63
|
+
else
|
64
|
+
self.start.date_time > Time.now
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def accepted?
|
69
|
+
response_status == Response::ACCEPTED
|
70
|
+
end
|
71
|
+
|
72
|
+
def declined?
|
73
|
+
response_status == Response::DECLINED
|
74
|
+
end
|
75
|
+
|
76
|
+
def one_on_one?
|
77
|
+
return false if attendees.nil?
|
78
|
+
return false unless attendees.any? { |a| a.self }
|
79
|
+
return false if human_attendees.length != 2
|
80
|
+
true
|
81
|
+
end
|
82
|
+
|
83
|
+
def busy?
|
84
|
+
transparency != Transparency::TRANSPARENT
|
85
|
+
end
|
86
|
+
|
87
|
+
def commitment?
|
88
|
+
return false if human_attendees.nil? || human_attendees.length < 2
|
89
|
+
return false if declined?
|
90
|
+
true
|
91
|
+
end
|
92
|
+
|
93
|
+
def private?
|
94
|
+
visibility == Visibility::PRIVATE
|
95
|
+
end
|
96
|
+
|
97
|
+
def start_time
|
98
|
+
if all_day?
|
99
|
+
self.start.to_date.beginning_of_day
|
100
|
+
else
|
101
|
+
self.start.date_time
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def start_date
|
106
|
+
if all_day?
|
107
|
+
self.start.to_date
|
108
|
+
else
|
109
|
+
self.start.date_time.to_date
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def human_attendees
|
114
|
+
return nil if attendees.nil?
|
115
|
+
attendees.select { |a| ! a.resource }
|
116
|
+
end
|
117
|
+
|
118
|
+
def attendee id
|
119
|
+
return nil if attendees.nil?
|
120
|
+
attendees.find do |attendee|
|
121
|
+
attendee.email == id
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def response_status
|
126
|
+
return Response::SELF if attendees.nil?
|
127
|
+
attendees.each do |attendee|
|
128
|
+
return attendee.response_status if attendee.self
|
129
|
+
end
|
130
|
+
nil
|
131
|
+
end
|
132
|
+
|
133
|
+
def av_uri
|
134
|
+
@av_uri ||= begin
|
135
|
+
zoom = CalendarAssistant::StringHelpers.find_uri_for_domain(description, "zoom.us")
|
136
|
+
return zoom if zoom
|
137
|
+
|
138
|
+
return hangout_link if hangout_link
|
139
|
+
nil
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def view_summary
|
144
|
+
return "(private)" if private? && (summary.nil? || summary.blank?)
|
145
|
+
return "(no title)" if summary.nil? || summary.blank?
|
146
|
+
summary
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
class Google::Apis::CalendarV3::EventDateTime
|
151
|
+
def to_date
|
152
|
+
return nil if @date.nil?
|
153
|
+
return Date.parse(@date) if @date.is_a?(String)
|
154
|
+
@date
|
155
|
+
end
|
156
|
+
|
157
|
+
def to_date!
|
158
|
+
return @date_time.to_date if @date.nil?
|
159
|
+
to_date
|
160
|
+
end
|
161
|
+
|
162
|
+
def to_s
|
163
|
+
return @date.to_s if @date
|
164
|
+
@date_time.strftime "%Y-%m-%d %H:%M"
|
165
|
+
end
|
166
|
+
|
167
|
+
def == lhs
|
168
|
+
if @date
|
169
|
+
return to_date == lhs.to_date
|
170
|
+
end
|
171
|
+
date_time == lhs.date_time
|
172
|
+
end
|
173
|
+
end
|