calendar-assistant 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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