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