teems 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- metadata +110 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Schedule bitmap rendering for who command
|
|
6
|
+
module WhoSchedule
|
|
7
|
+
SLOT_CHARS = { '0' => "\u2591", '1' => "\u2592", '2' => "\u2588", '3' => "\u2593", '4' => "\u2592" }.freeze
|
|
8
|
+
SLOTS_PER_HOUR = 4
|
|
9
|
+
DEFAULT_WORK_HOURS = [9, 17].freeze
|
|
10
|
+
STATUS_LABELS = { 'Available' => 'Available', 'Busy' => 'Busy', 'DoNotDisturb' => 'Do Not Disturb',
|
|
11
|
+
'BeRightBack' => 'Be Right Back', 'Away' => 'Away', 'Offline' => 'Offline' }.freeze
|
|
12
|
+
|
|
13
|
+
# Bundles email + timezone for schedule lookups
|
|
14
|
+
ScheduleTarget = Data.define(:email, :timezone)
|
|
15
|
+
# Bundles schedule display parameters including availability view
|
|
16
|
+
ScheduleContext = Data.define(:view, :work_start, :tz_abbrev)
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def fetch_schedule(target, work_start, work_end)
|
|
21
|
+
request_schedule(target, schedule_time_range(work_start, work_end))
|
|
22
|
+
rescue ApiError => e
|
|
23
|
+
debug("Could not fetch schedule for #{target.email}: #{e.message}")
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request_schedule(target, time_range)
|
|
28
|
+
tr = { start_time: time_range.first, end_time: time_range.last, timezone: target.timezone }
|
|
29
|
+
with_token_refresh { runner.users_api.schedule(target.email, time_range: tr) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def schedule_time_range(work_start, work_end)
|
|
33
|
+
[work_start, work_end].map { |hour| "#{Time.now.strftime('%Y-%m-%d')}T#{format('%02d', hour)}:00:00" }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_schedule(schedule, ctx)
|
|
37
|
+
view = schedule['availabilityView']
|
|
38
|
+
return unless view && !view.empty?
|
|
39
|
+
|
|
40
|
+
puts " Today #{render_bitmap(view)}"
|
|
41
|
+
puts " #{render_hour_labels(view, ctx.work_start)}"
|
|
42
|
+
render_now_marker(ctx.with(view: view))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render_bitmap(view)
|
|
46
|
+
view.chars.map { |ch| SLOT_CHARS[ch] || ch }.join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_hour_labels(view, work_start)
|
|
50
|
+
total_hours = view.length / SLOTS_PER_HOUR
|
|
51
|
+
(0...total_hours).map { |idx| format_hour_label(work_start + idx) }.join
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format_hour_label(hour) = (((hour - 1) % 12) + 1).to_s.ljust(SLOTS_PER_HOUR)
|
|
55
|
+
|
|
56
|
+
def render_now_marker(ctx)
|
|
57
|
+
slot_index = slot_for_now(ctx.work_start)
|
|
58
|
+
return unless slot_index >= 0 && slot_index < ctx.view.length
|
|
59
|
+
|
|
60
|
+
debug("Now marker at slot #{slot_index}")
|
|
61
|
+
local_time = Time.now.strftime('%-I:%M %p')
|
|
62
|
+
puts " #{' ' * slot_index}^ now #{local_time} #{ctx.tz_abbrev}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def render_calendar_line(schedule, ctx)
|
|
66
|
+
view = schedule['availabilityView']
|
|
67
|
+
return unless view && !view.empty?
|
|
68
|
+
|
|
69
|
+
status_text = compute_cal_status(ctx.with(view: view))
|
|
70
|
+
puts " Calendar #{status_text}" if status_text
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compute_cal_status(ctx)
|
|
74
|
+
view = ctx.view
|
|
75
|
+
slot = slot_for_now(ctx.work_start)
|
|
76
|
+
return nil unless slot >= 0 && slot < view.length
|
|
77
|
+
|
|
78
|
+
format_cal_label(view, slot, ctx)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_cal_label(view, slot, ctx)
|
|
82
|
+
label = view[slot] == '0' ? 'Free' : 'Busy'
|
|
83
|
+
"#{label}#{next_change_label(view, slot, ctx)}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def next_change_label(view, slot, ctx)
|
|
87
|
+
next_slot = find_next_change(view, slot)
|
|
88
|
+
return '' unless next_slot
|
|
89
|
+
|
|
90
|
+
change_time = slot_to_time(ctx.work_start, next_slot)
|
|
91
|
+
" until #{change_time.strftime('%-I:%M %p')} #{ctx.tz_abbrev}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def slot_to_time(work_start, slot_index)
|
|
95
|
+
offset = (work_start * 60) + (slot_index * 15)
|
|
96
|
+
Date.today.to_time + (offset * 60)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def slot_for_now(work_start)
|
|
100
|
+
now = Time.now
|
|
101
|
+
(((now.hour - work_start) * 60) + now.min) / 15
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_next_change(view, start_slot)
|
|
105
|
+
offset = start_slot + 1
|
|
106
|
+
match = search_view(view, offset, change_pattern(view[start_slot]))
|
|
107
|
+
match && (offset + match)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def search_view(view, offset, pattern) = view[offset..]&.index(pattern)
|
|
111
|
+
def change_pattern(slot_char) = free_slot?(slot_char) ? /[^0]/ : /0/
|
|
112
|
+
def free_slot?(char) = char == '0'
|
|
113
|
+
def parse_work_hours(schedule) = [parse_hour(schedule, 'startTime', 9), parse_hour(schedule, 'endTime', 17)]
|
|
114
|
+
|
|
115
|
+
def parse_hour(schedule, key, default)
|
|
116
|
+
value = schedule.dig('workingHours', key)
|
|
117
|
+
value ? value.split(':').first.to_i : default
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fetch_schedule_for(target)
|
|
121
|
+
return nil unless target.email
|
|
122
|
+
|
|
123
|
+
schedule = fetch_schedule(target, *DEFAULT_WORK_HOURS)
|
|
124
|
+
schedule && refetch_if_custom_hours(schedule, target)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def refetch_if_custom_hours(schedule, target)
|
|
128
|
+
actual_hours = parse_work_hours(schedule)
|
|
129
|
+
return schedule if actual_hours == DEFAULT_WORK_HOURS
|
|
130
|
+
|
|
131
|
+
fetch_schedule(target, *actual_hours) || schedule
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Presence display helpers for who command
|
|
136
|
+
module WhoPresence
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def render_presence_info(presence_data)
|
|
140
|
+
return unless presence_data
|
|
141
|
+
|
|
142
|
+
if presence_data.dig('presence', 'calendarData', 'isOutOfOffice')
|
|
143
|
+
render_oof_status(presence_data)
|
|
144
|
+
else
|
|
145
|
+
render_normal_status(presence_data)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_oof_status(presence_data)
|
|
150
|
+
render_normal_status(presence_data)
|
|
151
|
+
expiry = format_presence_expiry(presence_data)
|
|
152
|
+
text = expiry ? "Out of office (#{expiry})" : 'Out of office'
|
|
153
|
+
puts " OOF #{text}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def render_normal_status(presence_data)
|
|
157
|
+
label = presence_label(presence_data)
|
|
158
|
+
text = label_with_expiry(label, format_presence_expiry(presence_data))
|
|
159
|
+
puts " Status #{text}" if label
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def presence_label(presence_data)
|
|
163
|
+
availability = presence_data.dig('presence', 'availability')
|
|
164
|
+
WhoSchedule::STATUS_LABELS[availability] || availability
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def label_with_expiry(label, expiry)
|
|
168
|
+
expiry ? "#{label} (#{expiry})" : label
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def format_presence_expiry(presence_data)
|
|
172
|
+
expiry_str = presence_data.dig('presence', 'forcedAvailability', 'expiry')
|
|
173
|
+
return nil unless expiry_str
|
|
174
|
+
|
|
175
|
+
parse_expiry_time(expiry_str)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def parse_expiry_time(expiry_str)
|
|
179
|
+
expiry = Time.parse(expiry_str).localtime
|
|
180
|
+
"until #{expiry.strftime('%b %-d')}"
|
|
181
|
+
rescue ArgumentError
|
|
182
|
+
debug("Could not parse presence expiry: #{expiry_str.inspect}")
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Profile display helpers for who command
|
|
188
|
+
module WhoDisplay
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def render_profile(profile)
|
|
192
|
+
puts output.bold(profile.best_name)
|
|
193
|
+
render_profile_fields(profile)
|
|
194
|
+
render_phones(profile)
|
|
195
|
+
render_availability(profile)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render_profile_fields(profile)
|
|
199
|
+
render_field('Email', profile.email)
|
|
200
|
+
render_field('Title', profile.job_title)
|
|
201
|
+
render_field('Department', profile.department)
|
|
202
|
+
render_field('Office', profile.office_location)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def render_field(label, value)
|
|
206
|
+
puts " #{label.ljust(11)} #{value}" if present?(value)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def render_phones(profile)
|
|
210
|
+
render_field('Phone', profile.business_phones&.first)
|
|
211
|
+
render_field('Mobile', profile.mobile_phone)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def render_availability(profile)
|
|
215
|
+
presence_data = fetch_presence_data(profile.id)
|
|
216
|
+
render_presence_info(presence_data)
|
|
217
|
+
render_schedule_info(profile.email)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def render_schedule_info(email)
|
|
221
|
+
schedule = fetch_schedule_for(WhoSchedule::ScheduleTarget.new(email: email, timezone: detect_timezone))
|
|
222
|
+
return unless schedule
|
|
223
|
+
|
|
224
|
+
ctx = build_schedule_context(schedule)
|
|
225
|
+
render_calendar_line(schedule, ctx)
|
|
226
|
+
render_schedule(schedule, ctx)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_schedule_context(schedule)
|
|
230
|
+
work_start, = parse_work_hours(schedule)
|
|
231
|
+
WhoSchedule::ScheduleContext.new(view: schedule['availabilityView'],
|
|
232
|
+
work_start: work_start, tz_abbrev: short_tz_label)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def render_search_result(profile, index)
|
|
236
|
+
name, title, email = profile.search_display
|
|
237
|
+
title_suffix = present?(title) ? " (#{title})" : ''
|
|
238
|
+
puts " #{index + 1}. #{name}#{title_suffix}"
|
|
239
|
+
puts " #{email}" if present?(email)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def render_search_list(results)
|
|
243
|
+
results.each_with_index do |profile, index|
|
|
244
|
+
render_search_result(profile, index)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def search_results_json(results)
|
|
249
|
+
results.map(&:to_h)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def present?(value)
|
|
253
|
+
value && !value.empty?
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Look up a user's profile
|
|
258
|
+
class Who < Base
|
|
259
|
+
include Support::Timezone
|
|
260
|
+
include WhoSchedule
|
|
261
|
+
include WhoPresence
|
|
262
|
+
include WhoDisplay
|
|
263
|
+
|
|
264
|
+
def initialize(args, runner:)
|
|
265
|
+
@options = {}
|
|
266
|
+
super
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def execute
|
|
270
|
+
result = validate_options
|
|
271
|
+
return result if result
|
|
272
|
+
|
|
273
|
+
auth_result = require_auth
|
|
274
|
+
return auth_result if auth_result
|
|
275
|
+
|
|
276
|
+
lookup_user
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
protected
|
|
280
|
+
|
|
281
|
+
def help_text
|
|
282
|
+
<<~HELP
|
|
283
|
+
#{output.bold('teems who')} - Look up a user's profile
|
|
284
|
+
|
|
285
|
+
#{output.bold('USAGE:')}
|
|
286
|
+
teems who [options] Show your profile
|
|
287
|
+
teems who <query> [options] Search for a user
|
|
288
|
+
|
|
289
|
+
#{output.bold('OPTIONS:')}
|
|
290
|
+
-v, --verbose Show debug output
|
|
291
|
+
-q, --quiet Suppress output
|
|
292
|
+
--json Output as JSON
|
|
293
|
+
-h, --help Show this help
|
|
294
|
+
|
|
295
|
+
#{output.bold('EXAMPLES:')}
|
|
296
|
+
teems who # Show your own profile
|
|
297
|
+
teems who john # Search for "john"
|
|
298
|
+
teems who john@co.com # Look up by email
|
|
299
|
+
teems who --json # Your profile as JSON
|
|
300
|
+
HELP
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def lookup_user
|
|
306
|
+
dispatch_lookup(positional_args.join(' '))
|
|
307
|
+
rescue ApiError => e
|
|
308
|
+
error("Failed to look up user: #{e.message}")
|
|
309
|
+
1
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def dispatch_lookup(query)
|
|
313
|
+
query.empty? ? show_current_user : search_users(query)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def show_current_user
|
|
317
|
+
profile = with_token_refresh { runner.users_api.me }
|
|
318
|
+
display_profile(profile)
|
|
319
|
+
0
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def search_users(query)
|
|
323
|
+
results = with_token_refresh { runner.users_api.search(query) }
|
|
324
|
+
handle_search_results(results, query)
|
|
325
|
+
0
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def handle_search_results(results, query)
|
|
329
|
+
if results.empty?
|
|
330
|
+
puts "No users found matching '#{query}'"
|
|
331
|
+
elsif results.length == 1
|
|
332
|
+
display_profile(results.first)
|
|
333
|
+
else
|
|
334
|
+
display_search_results(results)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def display_profile(profile)
|
|
339
|
+
@options[:json] ? output_json(json_profile(*profile.json_attrs)) : render_profile(profile)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def display_search_results(results)
|
|
343
|
+
@options[:json] ? output_json(search_results_json(results)) : render_search_list(results)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def fetch_presence_data(user_id)
|
|
347
|
+
return nil unless user_id
|
|
348
|
+
|
|
349
|
+
fetch_teams_presence("8:orgid:#{user_id}")
|
|
350
|
+
rescue ApiError => e
|
|
351
|
+
debug("Could not fetch presence for #{user_id}: #{e.message}")
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def fetch_teams_presence(mri)
|
|
356
|
+
result = with_token_refresh { runner.users_api.teams_presence(mri) }
|
|
357
|
+
result&.first
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def schedule_target(email) = WhoSchedule::ScheduleTarget.new(email: email, timezone: detect_timezone)
|
|
361
|
+
|
|
362
|
+
def json_profile(attrs, user_id)
|
|
363
|
+
presence_data = fetch_presence_data(user_id)
|
|
364
|
+
schedule = fetch_schedule_for(schedule_target(attrs[:email]))
|
|
365
|
+
build_json_profile(attrs, presence_data, schedule)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def build_json_profile(attrs, presence_data, schedule)
|
|
369
|
+
result = attrs.merge(presence: presence_data&.dig('presence', 'availability'),
|
|
370
|
+
out_of_office: presence_data&.dig('presence', 'calendarData', 'isOutOfOffice'))
|
|
371
|
+
return result unless schedule
|
|
372
|
+
|
|
373
|
+
result.merge(availability_view: schedule['availabilityView'], working_hours: schedule['workingHours'])
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# Attendee formatting helpers for calendar events
|
|
6
|
+
module CalendarAttendeeFormatter
|
|
7
|
+
RESPONSE_LABELS = {
|
|
8
|
+
'accepted' => 'Accepted',
|
|
9
|
+
'declined' => 'Declined',
|
|
10
|
+
'tentativelyAccepted' => 'Tentative',
|
|
11
|
+
'none' => 'Pending'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
RESPONSE_SYMBOLS = {
|
|
15
|
+
'accepted' => [:green, "\u{2713}"], 'declined' => [:red, "\u{2717}"],
|
|
16
|
+
'tentativelyAccepted' => [:yellow, '?']
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def format_attendee_sections(event)
|
|
22
|
+
[].concat(format_attendee_group('Required Attendees:', event.required_attendees, event))
|
|
23
|
+
.concat(format_attendee_group('Optional Attendees:', event.optional_attendees, event))
|
|
24
|
+
.concat(format_untyped_attendees(event))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_attendee_group(title, attendees, event)
|
|
28
|
+
return [] unless attendees.any?
|
|
29
|
+
|
|
30
|
+
[@output.bold(title), *attendees.map { |attendee| format_attendee(attendee, event) }, '']
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_untyped_attendees(event)
|
|
34
|
+
untyped = event.attendees.reject { |attendee| %w[required optional].include?(attendee[:type]) }
|
|
35
|
+
format_attendee_group('Attendees:', untyped, event)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_attendee(attendee, event)
|
|
39
|
+
symbol = attendee_symbol(attendee, event)
|
|
40
|
+
label = response_label(attendee[:response])
|
|
41
|
+
display = format_name_email(attendee[:name], attendee[:email])
|
|
42
|
+
" #{symbol} #{display} \u2014 #{label}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def format_name_email(name, email)
|
|
46
|
+
name ? "#{@output.bold(name)} (#{email})" : email.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def attendee_symbol(att, event)
|
|
50
|
+
organizer = event.organizer
|
|
51
|
+
if organizer && att[:email]&.downcase == organizer[:email]&.downcase
|
|
52
|
+
@output.cyan("\u{2605}")
|
|
53
|
+
else
|
|
54
|
+
response_to_symbol(att[:response])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def response_to_symbol(response)
|
|
59
|
+
color, symbol = RESPONSE_SYMBOLS.fetch(response, [:gray, "\u{B7}"])
|
|
60
|
+
@output.public_send(color, symbol)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def response_label(response)
|
|
64
|
+
RESPONSE_LABELS[response] || response&.capitalize || 'Pending'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Detail view formatting for calendar events
|
|
69
|
+
module CalendarDetailFormatter
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def detail_metadata_lines(event)
|
|
73
|
+
lines = [@output.bold(event.subject), format_detail_time(event)]
|
|
74
|
+
lines.concat(detail_field_lines(event))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def detail_field_lines(event)
|
|
78
|
+
[detail_location(event), detail_organizer(event), detail_link(event),
|
|
79
|
+
detail_status(event), detail_response(event), detail_recurrence(event),
|
|
80
|
+
detail_show_as(event)].compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def detail_location(event)
|
|
84
|
+
loc = event.location
|
|
85
|
+
return unless loc && !loc.empty?
|
|
86
|
+
|
|
87
|
+
" #{@output.gray('Location:')} #{loc}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def detail_organizer(event)
|
|
91
|
+
org = event.organizer
|
|
92
|
+
return unless org
|
|
93
|
+
|
|
94
|
+
" #{@output.gray('Organizer:')} #{@output.bold(org[:name])} (#{org[:email]})"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def detail_link(event)
|
|
98
|
+
url = event.online_meeting_url
|
|
99
|
+
return unless url
|
|
100
|
+
|
|
101
|
+
" #{@output.gray('Link:')} #{url}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def detail_status(event) = event.cancelled? ? " Status: #{@output.red('CANCELLED')}" : nil
|
|
105
|
+
|
|
106
|
+
def detail_response(event)
|
|
107
|
+
response = event.response_status
|
|
108
|
+
return nil unless response
|
|
109
|
+
|
|
110
|
+
" RSVP: #{response_to_symbol(response)} #{response_label(response)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def detail_recurrence(event)
|
|
114
|
+
return unless event.recurring?
|
|
115
|
+
|
|
116
|
+
" #{@output.gray('Recurring:')} Yes"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def detail_show_as(event)
|
|
120
|
+
show_as = event.show_as
|
|
121
|
+
return unless show_as
|
|
122
|
+
|
|
123
|
+
" #{@output.gray('Show as:')} #{show_as}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detail_body_section(event)
|
|
127
|
+
preview = event.body_preview.to_s.strip
|
|
128
|
+
return [] if preview.empty?
|
|
129
|
+
|
|
130
|
+
[@output.bold('Description:'), " #{preview}", '']
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def format_detail_time(event)
|
|
134
|
+
time_label = @output.gray('Time:')
|
|
135
|
+
" #{time_label} #{detail_time_value(event)}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def detail_time_value(event)
|
|
139
|
+
return @output.bold('ALL DAY') if event.all_day?
|
|
140
|
+
|
|
141
|
+
start_time = event.start_time
|
|
142
|
+
range_display = event.time_range_display
|
|
143
|
+
return '(unknown)' unless start_time && range_display != ''
|
|
144
|
+
|
|
145
|
+
"#{@output.bold(start_time.strftime('%A, %B %-d, %Y'))} #{@output.gray(range_display)}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Formats calendar events for terminal display
|
|
150
|
+
class CalendarFormatter
|
|
151
|
+
include CalendarAttendeeFormatter
|
|
152
|
+
include CalendarDetailFormatter
|
|
153
|
+
|
|
154
|
+
RSVP_COUNTS = %i[accepted declined tentative pending].freeze
|
|
155
|
+
|
|
156
|
+
def initialize(output:)
|
|
157
|
+
@output = output
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Compact agenda listing
|
|
161
|
+
def format_event_list_compact(events)
|
|
162
|
+
events.each_with_index.map { |event, index| build_list_item_line(event, index + 1) }.join("\n")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Verbose agenda listing with organizer and RSVP summaries
|
|
166
|
+
def format_event_list_verbose(events)
|
|
167
|
+
events.each_with_index.flat_map do |event, index|
|
|
168
|
+
[build_list_item_line(event, index + 1) + list_item_verbose_suffix(event), '']
|
|
169
|
+
end.join("\n")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Detailed view of a single event
|
|
173
|
+
def format_event_detail(event)
|
|
174
|
+
[*detail_metadata_lines(event), '', *detail_body_section(event), *format_attendee_sections(event)].join("\n")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def build_list_item_line(event, number)
|
|
180
|
+
time = event.all_day? ? 'ALL DAY ' : event.time_range_display.ljust(11)
|
|
181
|
+
rsvp = response_to_symbol(event.response_status)
|
|
182
|
+
prefix = " #{@output.cyan("[#{number}]")} #{@output.gray("[#{event.short_hash}]")} #{time} #{rsvp}"
|
|
183
|
+
"#{prefix} #{format_event_subject(event)}#{list_item_location(event)}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def format_event_subject(event)
|
|
187
|
+
title = event.subject
|
|
188
|
+
suffix = subject_suffix(event)
|
|
189
|
+
suffix ? "#{title} #{suffix}" : title
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def subject_suffix(event)
|
|
193
|
+
return @output.gray('(cancelled)') if event.cancelled?
|
|
194
|
+
|
|
195
|
+
@output.gray('(recurring)') if event.recurring?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def list_item_location(event) = (loc = event.location) && !loc.empty? ? " #{@output.gray("(#{loc})")}" : ''
|
|
199
|
+
|
|
200
|
+
def list_item_verbose_suffix(event)
|
|
201
|
+
items = verbose_suffix_parts(event)
|
|
202
|
+
return '' if items.empty?
|
|
203
|
+
|
|
204
|
+
"\n #{items.join(" #{@output.gray('|')} ")}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def verbose_suffix_parts(event)
|
|
208
|
+
[organizer_label(event), attendee_rsvp_summary(event)].compact.reject(&:empty?)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def organizer_label(event)
|
|
212
|
+
organizer = event.organizer
|
|
213
|
+
return unless organizer
|
|
214
|
+
|
|
215
|
+
"#{@output.gray('Organizer:')} #{organizer[:name]}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def attendee_rsvp_summary(event)
|
|
219
|
+
counts = RSVP_COUNTS.filter_map do |status|
|
|
220
|
+
count = event.public_send(:"#{status}_attendees").length
|
|
221
|
+
"#{count} #{@output.gray(status.to_s)}" if count.positive?
|
|
222
|
+
end
|
|
223
|
+
counts.join(', ')
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# Shared stateless formatting utilities used across commands and formatters
|
|
6
|
+
module FormatUtils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def truncate(text, max = 120)
|
|
10
|
+
text.length > max ? "#{text[0...max]}..." : text
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format_bytes(bytes)
|
|
14
|
+
if bytes >= 1_048_576 then "#{(bytes / 1_048_576.0).round(1)} MB"
|
|
15
|
+
elsif bytes >= 1024 then "#{(bytes / 1024.0).round(1)} KB"
|
|
16
|
+
else "#{bytes} B"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def safe_filename(name)
|
|
21
|
+
base = File.basename(name)
|
|
22
|
+
base.empty? ? 'file' : base
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def attachment_name(att)
|
|
26
|
+
att.is_a?(Hash) ? (att['fileName'] || att['name'] || 'file') : att.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_single_reaction(reaction, emoji_map)
|
|
30
|
+
type = reaction[:type]
|
|
31
|
+
emoji = emoji_map[type] || type
|
|
32
|
+
count = reaction[:count] || 1
|
|
33
|
+
count > 1 ? "#{emoji}(#{count})" : emoji.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_time(time_string)
|
|
37
|
+
return '' unless time_string
|
|
38
|
+
|
|
39
|
+
Time.parse(time_string).getlocal.strftime('[%Y-%m-%d %H:%M]')
|
|
40
|
+
rescue ArgumentError
|
|
41
|
+
''
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def format_end_time(start_time, end_time)
|
|
45
|
+
fmt = (end_time - start_time) >= 86_400 ? '%b %-d, %-I:%M %p' : '%-I:%M %p'
|
|
46
|
+
end_time.strftime(fmt)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_time_range(start_str, end_match)
|
|
50
|
+
start_time = Time.parse(start_str).getlocal
|
|
51
|
+
end_time = end_match ? Time.parse(end_match[1]).getlocal : nil
|
|
52
|
+
[start_time, end_time]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|