calendar-assistant 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,51 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class Config < CalendarAssistant::Config
4
+ class TomlParseFailure < CalendarAssistant::BaseException;
5
+ end
6
+ class NoConfigFileToPersist < CalendarAssistant::BaseException;
7
+ end
8
+
9
+ CONFIG_FILE_PATH = File.join (ENV['CA_HOME'] || ENV["HOME"]), ".calendar-assistant"
10
+ attr_reader :config_file_path
11
+
12
+ def initialize options: {},
13
+ config_file_path: CONFIG_FILE_PATH,
14
+ defaults: DEFAULT_SETTINGS
15
+
16
+
17
+ @config_file_path = config_file_path
18
+
19
+ user_config = if File.exist? config_file_path
20
+ begin
21
+ FileUtils.chmod 0600, config_file_path
22
+ TOML.load_file config_file_path
23
+ rescue Exception => e
24
+ raise TomlParseFailure, "could not parse #{config_file_path}: #{e}"
25
+ end
26
+ else
27
+ Hash.new
28
+ end
29
+ super(options: options, defaults: defaults, user_config: user_config)
30
+ end
31
+
32
+ def profile_name
33
+ super.tap do |token|
34
+ persist!
35
+ end
36
+ end
37
+
38
+ def persist!
39
+ if config_file_path.nil?
40
+ raise NoConfigFileToPersist, "Cannot persist config when there's no config file"
41
+ end
42
+
43
+ content = TOML::Generator.new(user_config).body
44
+
45
+ File.open(config_file_path, "w") do |f|
46
+ f.write content
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,71 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class EventPresenter < SimpleDelegator
4
+ EMOJI_WARN = "⚠"
5
+
6
+ def description
7
+ s = formatted_event_date
8
+ s += rainbow.wrap(sprintf(" | %s", view_summary)).bold
9
+ s += event_attributes unless private?
10
+ s = rainbow.wrap(Rainbow.uncolor(s)).faint.strike if declined?
11
+ s
12
+ end
13
+
14
+ def view_summary
15
+ return "(private)" if private? && (summary.nil? || summary.blank?)
16
+ return "(no title)" if summary.nil? || summary.blank?
17
+ summary
18
+ end
19
+
20
+ private
21
+
22
+ def event_attributes
23
+ attributes = []
24
+
25
+ attributes << "recurring" if recurring?
26
+ attributes << "not-busy" unless busy?
27
+ attributes << "self" if self?
28
+ attributes << "1:1" if one_on_one?
29
+ attributes << "awaiting" if awaiting?
30
+ attributes << "tentative" if tentative?
31
+ attributes << rainbow.wrap(sprintf(" %s abandoned %s ", EMOJI_WARN, EMOJI_WARN)).red.bold.inverse if abandoned?
32
+
33
+ attributes << visibility if explicitly_visible?
34
+
35
+ attributes.empty? ? "" : rainbow.wrap(sprintf(" (%s)", attributes.to_a.sort.join(", "))).italic
36
+ end
37
+
38
+ def rainbow
39
+ @rainbow ||= Rainbow.global
40
+ end
41
+
42
+ def formatted_event_date
43
+ date = sprintf("%-25.25s", event_date)
44
+
45
+ date_ansi_codes = []
46
+ date_ansi_codes << :bright if current?
47
+ date_ansi_codes << :faint if past?
48
+
49
+ date_ansi_codes.inject(rainbow.wrap(date)) {|text, ansi| text.send ansi}
50
+ end
51
+
52
+ def event_date
53
+ if all_day?
54
+ start_date = __getobj__.start_date
55
+ end_date = __getobj__.end_date
56
+ if (end_date - start_date) <= 1
57
+ start.to_s
58
+ else
59
+ sprintf("%s - %s", start_date, end_date - 1.day)
60
+ end
61
+ else
62
+ if start_date == end_date
63
+ sprintf("%s - %s", start.date_time.strftime("%Y-%m-%d %H:%M"), __getobj__.end.date_time.strftime("%H:%M"))
64
+ else
65
+ sprintf("%s - %s", start.date_time.strftime("%Y-%m-%d %H:%M"), __getobj__.end.date_time.strftime("%Y-%m-%d %H:%M"))
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,69 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class EventSetPresenter < SimpleDelegator
4
+ def initialize(obj, config:, event_presenter_class: CLI::EventPresenter)
5
+ super(obj)
6
+ @config = config
7
+ @event_presenter_class = event_presenter_class
8
+ end
9
+
10
+ def to_s
11
+ [
12
+ title,
13
+ description
14
+ ].join("\n")
15
+ end
16
+
17
+ def title
18
+ rainbow.wrap("#{event_repository.calendar.id} (all times in #{event_repository.calendar.time_zone})\n").italic
19
+ end
20
+
21
+ def description
22
+ out = StringIO.new
23
+
24
+ if __getobj__.is_a?(EventSet::Hash)
25
+ events.each do |key, value|
26
+ out.puts rainbow.wrap(key.to_s.capitalize + ":").bold.italic
27
+ out.puts self.class.new(__getobj__.new(value), config: @config, event_presenter_class: @event_presenter_class).description
28
+ end
29
+ return out.string
30
+ end
31
+
32
+ _events = Array(events)
33
+
34
+ return "No events in this time range.\n" if _events.empty?
35
+
36
+ display_events = _events.select do |event|
37
+ !@config.setting(CalendarAssistant::Config::Keys::Options::COMMITMENTS) || event.commitment?
38
+ end
39
+
40
+ printed_now = false
41
+
42
+ display_events.each_with_object([]) do |event, out|
43
+ printed_now = now! event, printed_now, out: out, presenter_class: @event_presenter_class
44
+ out << @event_presenter_class.new(event).description
45
+ pp event if @config.debug?
46
+ end.join("\n")
47
+ end
48
+
49
+ def now!(event, printed_now, out:, presenter_class: CLI::EventPresenter)
50
+ return true if printed_now
51
+ return false if event.start_date != Date.today
52
+
53
+ if event.start_time > Time.now
54
+ out << presenter_class.new(CalendarAssistant::CLI::Helpers.now).description
55
+
56
+ return true
57
+ end
58
+
59
+ false
60
+ end
61
+
62
+ private
63
+
64
+ def rainbow
65
+ @rainbow ||= Rainbow.global
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,54 @@
1
+ # coding: utf-8
2
+ class CalendarAssistant
3
+ module CLI
4
+ module Helpers
5
+ class ChronicParseException < CalendarAssistant::BaseException;
6
+ end
7
+
8
+ def self.parse_datespec userspec
9
+ start_userspec, end_userspec = userspec.split(/ ?\.\.\.? ?/)
10
+
11
+ if end_userspec.nil?
12
+ time = Chronic.parse(userspec) || raise(ChronicParseException, "could not parse '#{userspec}'")
13
+ return time.beginning_of_day..time.end_of_day
14
+ end
15
+
16
+ start_time = Chronic.parse(start_userspec) || raise(ChronicParseException, "could not parse '#{start_userspec}'")
17
+ end_time = Chronic.parse(end_userspec) || raise(ChronicParseException, "could not parse '#{end_userspec}'")
18
+
19
+ if start_time.to_date == end_time.to_date
20
+ start_time..end_time
21
+ else
22
+ start_time.beginning_of_day..end_time.end_of_day
23
+ end
24
+ end
25
+
26
+ def self.now
27
+ CalendarAssistant::Event.new(
28
+ Google::Apis::CalendarV3::Event.new(start: Google::Apis::CalendarV3::EventDateTime.new(date_time: Time.now),
29
+ end: Google::Apis::CalendarV3::EventDateTime.new(date_time: Time.now),
30
+ summary: Rainbow(" now ").inverse.faint)
31
+ )
32
+ end
33
+
34
+ def self.find_av_uri ca, timespec
35
+ time = Chronic.parse timespec
36
+ range = time..(time + 5.minutes)
37
+ event_set = ca.find_events range
38
+
39
+ [CalendarAssistant::Event::Response::ACCEPTED,
40
+ CalendarAssistant::Event::Response::TENTATIVE,
41
+ CalendarAssistant::Event::Response::NEEDS_ACTION,
42
+ ].each do |response|
43
+ event_set.events.reverse.select do |event|
44
+ event.response_status == response
45
+ end.each do |event|
46
+ return [event_set.new(event), event.av_uri] if event.av_uri
47
+ end
48
+ end
49
+
50
+ event_set.new(nil)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class LinterEventPresenter < EventPresenter
4
+ EMOJI_ACCEPTED = "👍"
5
+ EMOJI_DECLINED = "👎"
6
+ EMOJI_NEEDS_ACTION = "🤷"
7
+ SUMMARY_THRESHOLD = 5
8
+
9
+ def description
10
+ s = formatted_event_date
11
+ date_length = s.length
12
+ s += rainbow.wrap(sprintf(" | %s", view_summary)).bold
13
+ s += event_attributes unless private?
14
+ s = rainbow.wrap(Rainbow.uncolor(s)).faint.strike if declined?
15
+ s += "\n #{' ' * (date_length + 2)}attendees: #{attendees}"
16
+ s
17
+ end
18
+
19
+ def attendees
20
+ if required_other_attendees .length > SUMMARY_THRESHOLD
21
+ summary_attendee_list
22
+ else
23
+ detailed_attendee_list
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def detailed_attendee_list
30
+ required_other_attendees.map do |attendee|
31
+ sprintf "%s %s", response_emoji(attendee.response_status), attendee.email || "<no email>"
32
+ end.join(", ")
33
+ end
34
+
35
+ def summary_attendee_list
36
+ summary = required_other_attendees.group_by do |attendee|
37
+ response_emoji(attendee.response_status)
38
+ end
39
+
40
+ summary.sort.map do |emoji, attendees|
41
+ "#{emoji} - #{attendees.count}"
42
+ end.join(", ")
43
+ end
44
+
45
+ def required_other_attendees
46
+ @required_other_attendees ||= (other_human_attendees || []).select {|a| !a.optional }
47
+ end
48
+
49
+ def response_emoji(response_status)
50
+ return EMOJI_ACCEPTED if response_status == CalendarAssistant::Event::Response::ACCEPTED
51
+ return EMOJI_DECLINED if response_status == CalendarAssistant::Event::Response::DECLINED
52
+ return EMOJI_NEEDS_ACTION
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class LinterEventSetPresenter < EventSetPresenter
4
+ def initialize(obj, config:, event_presenter_class: CLI::LinterEventPresenter)
5
+ super(obj, config: config, event_presenter_class: event_presenter_class)
6
+ end
7
+
8
+ def title
9
+ rainbow.wrap(<<~OUT)
10
+ #{event_repository.calendar.id}
11
+ - looking for events that need attention
12
+ - all times in #{event_repository.calendar.time_zone}
13
+ OUT
14
+ end
15
+
16
+ private
17
+
18
+ def rainbow
19
+ @rainbow ||= Rainbow.global
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,90 @@
1
+ class CalendarAssistant
2
+ module CLI
3
+ class Printer
4
+
5
+ attr_reader :io
6
+
7
+ def initialize io = STDOUT
8
+ @io = io
9
+ end
10
+
11
+ def launch url
12
+ Launchy.open url
13
+ end
14
+
15
+ def puts *args
16
+ io.puts(*args)
17
+ end
18
+
19
+ def prompt query, default = nil
20
+ loop do
21
+ message = query
22
+ message += " [#{default}]" if default
23
+ message += ": "
24
+ print Rainbow(message).bold
25
+ answer = STDIN.gets.chomp.strip
26
+ if answer.empty?
27
+ return default if default
28
+ puts Rainbow("Please provide an answer.").red
29
+ else
30
+ return answer
31
+ end
32
+ end
33
+ end
34
+
35
+ def print_events ca, event_set, omit_title: false, presenter_class: CLI::EventSetPresenter
36
+ puts presenter_class.new(event_set, config: ca.config).to_s
37
+ puts
38
+ end
39
+
40
+ def print_available_blocks ca, event_set, omit_title: false
41
+ ers = ca.config.attendees.map {|calendar_id| ca.event_repository calendar_id}
42
+ time_zones = ers.map {|er| er.calendar.time_zone}.uniq
43
+
44
+ unless omit_title
45
+ puts Rainbow(ers.map {|er| er.calendar.id}.join(", ")).italic
46
+ puts Rainbow(sprintf("- looking for blocks at least %s long",
47
+ ChronicDuration.output(
48
+ ChronicDuration.parse(
49
+ ca.config.setting(Config::Keys::Settings::MEETING_LENGTH))))).italic
50
+ time_zones.each do |time_zone|
51
+ puts Rainbow(sprintf("- between %s and %s in %s",
52
+ ca.config.setting(Config::Keys::Settings::START_OF_DAY),
53
+ ca.config.setting(Config::Keys::Settings::END_OF_DAY),
54
+ time_zone,
55
+ )).italic
56
+ end
57
+ puts
58
+ end
59
+
60
+ if event_set.is_a?(EventSet::Hash)
61
+ event_set.events.each do |key, value|
62
+ puts(sprintf(Rainbow("Availability on %s:\n").bold,
63
+ key.strftime("%A, %B %-d")))
64
+ print_available_blocks ca, event_set.new(value), omit_title: true
65
+ puts
66
+ end
67
+ return
68
+ end
69
+
70
+ events = Array(event_set.events)
71
+ if events.empty?
72
+ puts " (No available blocks in this time range.)"
73
+ return
74
+ end
75
+
76
+ events.each do |event|
77
+ line = []
78
+ time_zones.each do |time_zone|
79
+ line << sprintf("%s - %s",
80
+ event.start_time.in_time_zone(time_zone).strftime("%l:%M%P"),
81
+ event.end_time.in_time_zone(time_zone).strftime("%l:%M%P %Z"))
82
+ end
83
+ line.uniq!
84
+ puts " • " + line.join(" / ") + Rainbow(" (" + event.duration + ")").italic
85
+ pp event if ca.config.debug?
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -2,13 +2,10 @@ class CalendarAssistant
2
2
  class Config
3
3
  autoload :TokenStore, "calendar_assistant/config/token_store"
4
4
 
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
- DEFAULT_CALENDAR_ID = "primary"
5
+ class NoTokensAuthorized < CalendarAssistant::BaseException;
6
+ end
7
+ class AccessingHashAsScalar < CalendarAssistant::BaseException;
8
+ end
12
9
 
13
10
  module Keys
14
11
  TOKENS = "tokens"
@@ -23,6 +20,7 @@ class CalendarAssistant
23
20
  MEETING_LENGTH = "meeting-length" # ChronicDuration
24
21
  START_OF_DAY = "start-of-day" # BusinessTime
25
22
  END_OF_DAY = "end-of-day" # BusinessTime
23
+ LOCATION_ICONS = "location-icons" # Location Icons
26
24
  end
27
25
 
28
26
  #
@@ -35,45 +33,30 @@ class CalendarAssistant
35
33
  ATTENDEES = "attendees" # array of calendar ids (comma-delimited)
36
34
  LOCAL_STORE = "local-store" # filename
37
35
  DEBUG = "debug" # bool
36
+ FORMATTING = "formatting" # Rainbow
38
37
  end
39
38
  end
40
39
 
40
+ DEFAULT_CALENDAR_ID = "primary"
41
+
41
42
  DEFAULT_SETTINGS = {
43
+ Keys::Settings::LOCATION_ICONS => ["🗺 ", "🌎"], # Location Icons
42
44
  Keys::Settings::MEETING_LENGTH => "30m", # ChronicDuration
43
45
  Keys::Settings::START_OF_DAY => "9am", # BusinessTime
44
46
  Keys::Settings::END_OF_DAY => "6pm", # BusinessTime
45
47
  Keys::Options::ATTENDEES => [DEFAULT_CALENDAR_ID], # array of calendar ids
48
+ Keys::Options::FORMATTING => true, # Rainbow
46
49
  }
47
50
 
48
- attr_reader :config_file_path, :user_config, :options, :defaults
51
+ attr_reader :user_config, :options, :defaults
49
52
 
50
53
  def initialize options: {},
51
- config_file_path: CONFIG_FILE_PATH,
52
- config_io: nil,
54
+ user_config: {},
53
55
  defaults: DEFAULT_SETTINGS
54
- if config_io.nil?
55
- @config_file_path = config_file_path
56
- end
57
-
58
- @user_config = if config_io
59
- begin
60
- TOML.load config_io.read
61
- rescue Exception => e
62
- raise TomlParseFailure, "could not parse IO stream: #{e}"
63
- end
64
- elsif File.exist? config_file_path
65
- begin
66
- FileUtils.chmod 0600, config_file_path
67
- TOML.load_file config_file_path
68
- rescue Exception => e
69
- raise TomlParseFailure, "could not parse #{config_file_path}: #{e}"
70
- end
71
- else
72
- Hash.new
73
- end
74
56
 
75
57
  @defaults = defaults
76
58
  @options = options
59
+ @user_config = user_config
77
60
  end
78
61
 
79
62
  def in_env &block
@@ -101,11 +84,10 @@ class CalendarAssistant
101
84
  # finally we'll grab the first configured token and set that as the default
102
85
  token_names = tokens.keys
103
86
  if token_names.empty?
104
- raise NoTokensAuthorized, "Please run `calendar-assistant help authorize` for help."
87
+ raise CalendarAssistant::Config::NoTokensAuthorized, "Please run `calendar-assistant help authorize` for help."
105
88
  end
106
89
  token_names.first.tap do |new_default|
107
90
  Config.set_in_hash user_config, [Keys::SETTINGS, Keys::Settings::PROFILE], new_default
108
- persist!
109
91
  end
110
92
  end
111
93
 
@@ -113,7 +95,7 @@ class CalendarAssistant
113
95
  rval = Config.find_in_hash(user_config, keypath)
114
96
 
115
97
  if rval.is_a?(Hash)
116
- raise AccessingHashAsScalar, "keypath #{keypath} is not a scalar"
98
+ raise CalendarAssistant::Config::AccessingHashAsScalar, "keypath #{keypath} is not a scalar"
117
99
  end
118
100
 
119
101
  rval
@@ -133,6 +115,8 @@ class CalendarAssistant
133
115
  Config.find_in_hash(defaults, setting_name)
134
116
  end
135
117
 
118
+ alias_method :[], :setting
119
+
136
120
  def settings
137
121
  setting_names = CalendarAssistant::Config::Keys::Settings.constants.map do |k|
138
122
  CalendarAssistant::Config::Keys::Settings.const_get k
@@ -152,18 +136,6 @@ class CalendarAssistant
152
136
  CalendarAssistant::Config::TokenStore.new self
153
137
  end
154
138
 
155
- def persist!
156
- if config_file_path.nil?
157
- raise NoConfigFileToPersist, "Cannot persist config when initialized with an IO"
158
- end
159
-
160
- content = TOML::Generator.new(user_config).body
161
-
162
- File.open(config_file_path, "w") do |f|
163
- f.write content
164
- end
165
- end
166
-
167
139
  #
168
140
  # helper method for Keys::Options::ATTENDEES
169
141
  #
@@ -179,6 +151,10 @@ class CalendarAssistant
179
151
  setting(Keys::Options::DEBUG)
180
152
  end
181
153
 
154
+ def persist!
155
+ #noop
156
+ end
157
+
182
158
  private
183
159
 
184
160
  def self.find_in_hash hash, keypath