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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. 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