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