calendar-assistant 0.6.0 → 0.7.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,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