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,891 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ CAL_HELP = <<~HELP
6
+ teems cal - List calendar events and view details
7
+
8
+ USAGE:
9
+ teems cal [options] List today's events (interactive when TTY)
10
+ teems cal today List today's events (alias)
11
+ teems cal tomorrow List tomorrow's events
12
+ teems cal show <N|hash> Show details for event by # or hash
13
+ teems cal accept <N|hash> Accept event by # or hash
14
+ teems cal decline <N|hash> Decline event by # or hash
15
+ teems cal tentative <N|hash> Tentatively accept event by # or hash
16
+ teems cal create "Title" [opts] Create a new event
17
+ teems cal delete <N|hash> Delete event by # or hash
18
+
19
+ OPTIONS:
20
+ --days N Show events for the next N days (default: 1)
21
+ --week Show events for the current week (Mon-Fri)
22
+ --date YYYY-MM-DD Show events for a specific date
23
+ --no-interactive Disable interactive mode (list and exit)
24
+ --comment TEXT Add a comment to RSVP response
25
+ --no-send Don't send response to organizer
26
+ -n, --limit N Maximum number of events to show
27
+ -v, --verbose Show attendee summaries
28
+ -q, --quiet Suppress output
29
+ --json Output as JSON
30
+ -h, --help Show this help
31
+
32
+ CREATE OPTIONS:
33
+ --start TIME Start time: "YYYY-MM-DD HH:MM", "today HH:MM",
34
+ "tomorrow HH:MM", or "HH:MM" (assumes today)
35
+ --end TIME End time (default: start + 30 minutes)
36
+ --duration MIN Duration in minutes (alternative to --end)
37
+ --all-day Create an all-day event (use with --date)
38
+ --location TEXT Event location
39
+ --body TEXT Event description (plain text)
40
+ --html TEXT Event description (HTML)
41
+ --attendees EMAILS Comma-separated required attendee emails
42
+ --optional EMAILS Comma-separated optional attendee emails
43
+ --room EMAILS Comma-separated room/resource emails
44
+ --teams Add a Teams online meeting link
45
+ --no-teams Explicitly disable Teams meeting
46
+ --show-as STATUS free, tentative, busy, oof, workingElsewhere
47
+ --importance LEVEL low, normal, high
48
+ --sensitivity LEVEL normal, personal, private, confidential
49
+ --reminder MIN Reminder minutes before start
50
+ --no-reminder Disable reminder
51
+ --no-rsvp Don't request responses from attendees
52
+ --no-time-proposals Don't allow new time proposals
53
+ --hide-attendees Hide attendee list from other attendees
54
+
55
+ EXAMPLES:
56
+ teems cal # Interactive agenda (TTY)
57
+ teems cal --no-interactive # List and exit
58
+ teems cal today # Same as above
59
+ teems cal tomorrow # Tomorrow's events
60
+ teems cal --days 3 # Next 3 days
61
+ teems cal --week # This work week
62
+ teems cal --date 2026-01-20 # Specific date
63
+ teems cal show 3 # Details for event #3
64
+ teems cal show a3f2b1 # Details by short hash
65
+ teems cal accept 3 # Accept event #3
66
+ teems cal accept a3f2 # Accept by hash prefix
67
+ teems cal decline 3 --comment "Out of office"
68
+ teems cal create "Standup" --start "tomorrow 09:00" --duration 15
69
+ teems cal create "Review" --start "2026-03-20 14:00" --teams \
70
+ --attendees alice@example.com,bob@example.com
71
+ teems cal delete 3 # Delete event #3
72
+ teems cal --json | jq ... # JSON output, no prompt
73
+ HELP
74
+
75
+ # Date range computation for calendar queries
76
+ module CalDateRange
77
+ # Map common timezone abbreviations to IANA names
78
+ TIMEZONE_MAP = {
79
+ 'EST' => 'America/New_York', 'EDT' => 'America/New_York',
80
+ 'CST' => 'America/Chicago', 'CDT' => 'America/Chicago',
81
+ 'MST' => 'America/Denver', 'MDT' => 'America/Denver',
82
+ 'PST' => 'America/Los_Angeles', 'PDT' => 'America/Los_Angeles',
83
+ 'AKST' => 'America/Anchorage', 'AKDT' => 'America/Anchorage',
84
+ 'HST' => 'Pacific/Honolulu', 'UTC' => 'UTC', 'GMT' => 'UTC'
85
+ }.freeze
86
+
87
+ private
88
+
89
+ def detect_timezone
90
+ tz_from_env = resolve_tz_env
91
+ tz_from_env || timezone_from_system
92
+ end
93
+
94
+ def resolve_tz_env
95
+ tz_env = ENV.fetch('TZ', '')
96
+ return if tz_env.empty?
97
+
98
+ TIMEZONE_MAP.fetch(tz_env) { tz_env }
99
+ end
100
+
101
+ def timezone_from_system
102
+ zone_abbrev = Time.now.strftime('%Z')
103
+ TIMEZONE_MAP[zone_abbrev] || 'UTC'
104
+ end
105
+
106
+ def compute_date_range
107
+ start_dt, end_dt = date_range_boundaries
108
+ [format_datetime(start_dt), format_datetime(end_dt)]
109
+ rescue Date::Error
110
+ nil
111
+ end
112
+
113
+ def date_range_boundaries
114
+ if @options[:date]
115
+ date_range_for_date
116
+ elsif @options[:week]
117
+ date_range_for_week
118
+ else
119
+ date_range_for_days
120
+ end
121
+ end
122
+
123
+ def date_range_for_date
124
+ date = Date.parse(@options[:date])
125
+ [day_start(date), day_end(date)]
126
+ end
127
+
128
+ def date_range_for_week
129
+ monday = week_monday
130
+ [day_start(monday), day_end(monday + 4)]
131
+ end
132
+
133
+ def week_monday
134
+ @week_monday ||= compute_week_monday(@options[:days] || 5)
135
+ end
136
+
137
+ def compute_week_monday(_week_length)
138
+ today = Date.today
139
+ offset = today.wday
140
+ today - (offset.zero? ? 6 : offset - 1)
141
+ end
142
+
143
+ def date_range_for_days
144
+ today = Date.today
145
+ end_date = today + (@options[:days] || 1) - 1
146
+ [day_start(today), day_end(end_date)]
147
+ end
148
+
149
+ def day_start(date)
150
+ Time.new(date.year, date.month, date.day, 0, 0, 0)
151
+ end
152
+
153
+ def day_end(date)
154
+ Time.new(date.year, date.month, date.day, 23, 59, 59)
155
+ end
156
+
157
+ def format_datetime(time) = time.strftime('%Y-%m-%dT%H:%M:%S%:z')
158
+ end
159
+
160
+ # Subcommand parsing for cal command
161
+ module CalSubcommandParser
162
+ RSVP_ACTIONS = %w[accept decline tentative].freeze
163
+
164
+ SUBCOMMAND_PARSERS = {
165
+ 'show' => :parse_show_subcommand, 'today' => :parse_today_subcommand,
166
+ 'tomorrow' => :parse_tomorrow_subcommand, 'create' => :parse_create_subcommand,
167
+ 'delete' => :parse_delete_subcommand
168
+ }.freeze
169
+
170
+ private
171
+
172
+ def parse_options(args)
173
+ remaining = super
174
+ parse_subcommand(remaining)
175
+ end
176
+
177
+ def parse_subcommand(remaining)
178
+ dispatch_subcommand_parse(remaining)
179
+ remaining
180
+ end
181
+
182
+ def dispatch_subcommand_parse(remaining)
183
+ subcommand = remaining.first
184
+ parser = SUBCOMMAND_PARSERS[subcommand]
185
+ if parser then send(parser, remaining)
186
+ elsif RSVP_ACTIONS.include?(subcommand) then parse_rsvp_subcommand(remaining)
187
+ else @subcommand = 'list'
188
+ end
189
+ end
190
+
191
+ def parse_show_subcommand(remaining)
192
+ @subcommand = 'show'
193
+ _subcommand, event_arg = remaining.shift(2)
194
+ @event_ref = event_arg
195
+ end
196
+
197
+ def parse_today_subcommand(remaining)
198
+ @subcommand = 'list'
199
+ remaining.shift
200
+ end
201
+
202
+ def parse_tomorrow_subcommand(remaining)
203
+ @subcommand = 'list'
204
+ remaining.shift
205
+ @options[:date] = (Date.today + 1).to_s
206
+ end
207
+
208
+ def parse_rsvp_subcommand(remaining)
209
+ action, event_arg = remaining.shift(2)
210
+ @subcommand = action
211
+ @event_ref = event_arg
212
+ end
213
+
214
+ def parse_create_subcommand(remaining)
215
+ @subcommand = 'create'
216
+ _subcommand, subject = remaining.shift(2)
217
+ @create_subject = subject
218
+ end
219
+
220
+ def parse_delete_subcommand(remaining)
221
+ @subcommand = 'delete'
222
+ _subcommand, event_arg = remaining.shift(2)
223
+ @event_ref = event_arg
224
+ end
225
+ end
226
+
227
+ # Event resolution by number or short hash
228
+ module CalEventResolver
229
+ private
230
+
231
+ def resolve_event_id
232
+ @resolve_events = fetch_current_events
233
+ event = resolve_by_ref(@event_ref)
234
+ return event.id if event
235
+
236
+ error("Event '#{@event_ref}' not found")
237
+ nil
238
+ end
239
+
240
+ def resolve_by_ref(ref)
241
+ ref.match?(/\A\d+\z/) ? event_by_number(ref) : event_by_hash_prefix(ref)
242
+ end
243
+
244
+ def event_by_number(ref)
245
+ index = ref.to_i - 1
246
+ index >= 0 ? @resolve_events[index] : nil
247
+ end
248
+
249
+ def event_by_hash_prefix(ref)
250
+ @hash_matches = @resolve_events.select { |evt| evt.short_hash.start_with?(ref.downcase) }
251
+ @hash_matches.first if @hash_matches.one?
252
+ end
253
+
254
+ def fetch_current_events
255
+ range = compute_date_range
256
+ return [] unless range
257
+
258
+ fetch_events(range)
259
+ end
260
+ end
261
+
262
+ # Event display, RSVP, and detail subcommands
263
+ module CalEventActions
264
+ RSVP_ACTION_LABELS = {
265
+ 'accept' => 'accepted', 'decline' => 'declined', 'tentative' => 'tentatively accepted'
266
+ }.freeze
267
+
268
+ private
269
+
270
+ def show_event
271
+ event_id = validated_event_id
272
+ return event_id if event_id.is_a?(Integer)
273
+
274
+ render_single_event(event_id)
275
+ rescue ApiError => e
276
+ api_error_result('Failed to fetch event', e)
277
+ end
278
+
279
+ def validated_event_id
280
+ return missing_event_ref unless @event_ref && !@event_ref.empty?
281
+
282
+ resolve_event_id || 1
283
+ end
284
+
285
+ def api_error_result(prefix, err)
286
+ error("#{prefix}: #{err.message}")
287
+ 1
288
+ end
289
+
290
+ def render_single_event(event_id)
291
+ event = fetch_event_for_display(event_id)
292
+ render_event_output(event)
293
+ 0
294
+ end
295
+
296
+ def render_event_output(event)
297
+ if @options[:json]
298
+ output_json(event_to_hash(event))
299
+ else
300
+ formatter = Formatters::CalendarFormatter.new(output: output)
301
+ puts formatter.format_event_detail(event)
302
+ end
303
+ end
304
+
305
+ def rsvp_event
306
+ event_id = validated_event_id
307
+ return event_id if event_id.is_a?(Integer)
308
+
309
+ send_rsvp(event_id)
310
+ rescue ApiError => e
311
+ api_error_result('Failed to respond to event', e)
312
+ end
313
+
314
+ def send_rsvp(event_id)
315
+ with_token_refresh do
316
+ runner.calendar_api.rsvp_event(
317
+ event_id: event_id, action: @subcommand,
318
+ comment: @options[:comment],
319
+ notify: @options[:no_send] ? :silent : :send
320
+ )
321
+ end
322
+
323
+ success("Event #{@event_ref} #{RSVP_ACTION_LABELS[@subcommand]}")
324
+ 0
325
+ end
326
+
327
+ def missing_event_ref
328
+ action = @subcommand == 'show' ? 'show' : @subcommand
329
+ error("Event reference required. Usage: teems cal #{action} <N|hash>")
330
+ 1
331
+ end
332
+
333
+ def delete_event
334
+ event_id = validated_event_id
335
+ return event_id if event_id.is_a?(Integer)
336
+
337
+ send_delete(event_id)
338
+ end
339
+
340
+ def send_delete(event_id)
341
+ event = fetch_and_delete(event_id)
342
+ display_delete_result(event)
343
+ 0
344
+ rescue ApiError => e
345
+ api_error_result('Failed to delete event', e)
346
+ end
347
+
348
+ def fetch_and_delete(event_id)
349
+ event = fetch_event_for_display(event_id)
350
+ with_token_refresh { runner.calendar_api.delete_event(event_id: event_id) }
351
+ event
352
+ end
353
+
354
+ def fetch_event_for_display(event_id)
355
+ with_token_refresh { runner.calendar_api.get_event(event_id: event_id, timezone: detect_timezone) }
356
+ end
357
+
358
+ def display_delete_result(event)
359
+ success("Deleted: \"#{event.subject}\"")
360
+ event.create_summary_lines.each { |line| puts " #{line}" }
361
+ end
362
+
363
+ def event_to_hash(event)
364
+ event.to_h.merge(start_time: event.start_time&.iso8601, end_time: event.end_time&.iso8601)
365
+ end
366
+ end
367
+
368
+ # Interactive event selection and action loop for TTY mode
369
+ module CalInteractiveMode
370
+ RSVP_KEYS = { 'a' => 'accept', 'd' => 'decline', 't' => 'tentative' }.freeze
371
+
372
+ private
373
+
374
+ def interactive?
375
+ !@options[:json] && !@options[:no_interactive] && !@options[:quiet] && output.tty?
376
+ end
377
+
378
+ def interactive_event_loop(events)
379
+ setup_interactive_state(events)
380
+ run_selection_loop
381
+ end
382
+
383
+ def setup_interactive_state(events)
384
+ @interactive_events = events
385
+ @resolve_events = events
386
+ end
387
+
388
+ def run_selection_loop
389
+ loop do
390
+ input = read_selection_input
391
+ return unless input
392
+
393
+ handle_selection(input)
394
+ end
395
+ end
396
+
397
+ def read_selection_input
398
+ output.print "\nEnter # or hash for details (1-#{@interactive_events.length}) or q to quit: "
399
+ output.flush
400
+ @last_input = $stdin.gets&.strip.to_s
401
+ @last_input unless @last_input.empty? || @last_input == 'q'
402
+ end
403
+
404
+ def handle_selection(input)
405
+ event = resolve_by_ref(input)
406
+ event ? show_detail_and_act(event) : output.puts("Invalid selection: #{input}")
407
+ end
408
+
409
+ def show_detail_and_act(event)
410
+ render_event_detail_text(event)
411
+ action_loop(event)
412
+ end
413
+
414
+ def render_event_detail_text(event)
415
+ formatter = Formatters::CalendarFormatter.new(output: output)
416
+ output.puts ''
417
+ output.puts formatter.format_event_detail(event)
418
+ end
419
+
420
+ def action_loop(event)
421
+ loop do
422
+ input = read_action_input
423
+ result = dispatch_action(input, event)
424
+ return result unless result == :continue
425
+ end
426
+ end
427
+
428
+ def read_action_input
429
+ output.print "\n[a]ccept [d]ecline [t]entative [D]elete [b]ack [q]uit: "
430
+ output.flush
431
+ $stdin.gets&.strip
432
+ end
433
+
434
+ def dispatch_action(input, event)
435
+ return send_interactive_rsvp(RSVP_KEYS[input], event) if RSVP_KEYS.key?(input)
436
+
437
+ dispatch_non_rsvp_action(input, event)
438
+ end
439
+
440
+ def dispatch_non_rsvp_action(input, event)
441
+ case input.to_s
442
+ when 'D' then send_interactive_delete(event)
443
+ when 'b' then redisplay_list
444
+ when 'q', '' then throw(:interactive_quit)
445
+ else output.puts("Unknown action: #{input}") || :continue
446
+ end
447
+ end
448
+
449
+ def send_interactive_rsvp(action, event)
450
+ with_token_refresh { runner.calendar_api.rsvp_event(event_id: event.id, action: action, notify: :send) }
451
+ display_rsvp_result(action, event)
452
+ rescue ApiError => e
453
+ error("Failed to respond: #{e.message}") && :continue
454
+ end
455
+
456
+ def display_rsvp_result(action, event)
457
+ success("Event #{CalEventActions::RSVP_ACTION_LABELS[action]}: \"#{event.subject}\"")
458
+ :continue
459
+ end
460
+
461
+ def send_interactive_delete(event)
462
+ perform_delete(event)
463
+ throw(:interactive_quit)
464
+ rescue ApiError => e
465
+ error("Failed to delete: #{e.message}") && :continue
466
+ end
467
+
468
+ def perform_delete(event)
469
+ with_token_refresh { runner.calendar_api.delete_event(event_id: event.id) }
470
+ success("Deleted: \"#{event.subject}\"")
471
+ end
472
+
473
+ def redisplay_list
474
+ output.puts ''
475
+ render_events_text(@interactive_events)
476
+ nil
477
+ end
478
+ end
479
+
480
+ # Time parsing for create subcommand
481
+ module CalTimeParsing
482
+ private
483
+
484
+ def parse_time_input(raw)
485
+ date, time_str = split_time_input(raw)
486
+ return unless time_str
487
+
488
+ date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
489
+ end
490
+
491
+ def split_time_input(raw)
492
+ base = Date.today
493
+ split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
494
+ end
495
+
496
+ def split_tomorrow_time(raw, base)
497
+ return unless raw.start_with?('tomorrow ')
498
+
499
+ [base + 1, raw.delete_prefix('tomorrow ')]
500
+ end
501
+
502
+ def split_today_time(raw, base)
503
+ return unless raw.match?(/\A(?:today\s+)?\d{1,2}:\d{2}\z/)
504
+
505
+ [base, raw.delete_prefix('today ')]
506
+ end
507
+
508
+ def split_absolute_time(raw)
509
+ return unless raw.match?(/\A\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\z/)
510
+
511
+ raw.split(/\s+/, 2)
512
+ end
513
+
514
+ def parse_relative_time(date, time_str)
515
+ hour, min = time_str.split(':').map(&:to_i)
516
+ debug("Parsing time #{hour}:#{min} for #{@options[:date]}") if @options[:verbose]
517
+ Time.new(date.year, date.month, date.day, hour, min, 0)
518
+ rescue ArgumentError
519
+ nil
520
+ end
521
+
522
+ def parse_absolute_time(date_str, time_str)
523
+ date = Date.parse(date_str)
524
+ parse_relative_time(date, time_str)
525
+ rescue Date::Error
526
+ nil
527
+ end
528
+ end
529
+
530
+ # Builds the event body hash fields for create requests
531
+ module CalEventFields
532
+ private
533
+
534
+ def add_optional_event_fields(body)
535
+ add_location_field(body)
536
+ add_body_field(body)
537
+ add_all_attendees(body)
538
+ add_meeting_flags(body)
539
+ add_display_fields(body)
540
+ body
541
+ end
542
+
543
+ def add_location_field(body)
544
+ location = @options[:location]
545
+ body[:location] = { displayName: location } if location
546
+ end
547
+
548
+ def add_body_field(body)
549
+ html = @options[:html]
550
+ text = @options[:body]
551
+ body[:body] = { contentType: 'html', content: html } if html
552
+ body[:body] = { contentType: 'text', content: text } if text && !html
553
+ end
554
+
555
+ def add_all_attendees(body)
556
+ list = collect_attendees
557
+ body[:attendees] = list unless list.empty?
558
+ end
559
+
560
+ def collect_attendees
561
+ entries = []
562
+ entries.concat(typed_attendees(@options[:attendees], 'required'))
563
+ entries.concat(typed_attendees(@options[:optional], 'optional'))
564
+ entries.concat(typed_attendees(@options[:rooms], 'resource'))
565
+ entries
566
+ end
567
+
568
+ def typed_attendees(emails, type)
569
+ return [] unless emails
570
+
571
+ emails.map { |email| { emailAddress: { address: email }, type: type } }
572
+ end
573
+
574
+ def add_meeting_flags(body)
575
+ apply_teams_setting(body)
576
+ body[:responseRequested] = false if @options[:no_rsvp]
577
+ body[:allowNewTimeProposals] = false if @options[:no_time_proposals]
578
+ body[:hideAttendees] = true if @options[:hide_attendees]
579
+ end
580
+
581
+ def apply_teams_setting(body)
582
+ teams = @options[:teams]
583
+ return unless teams || @options[:no_teams]
584
+
585
+ body[:isOnlineMeeting] = teams || false
586
+ body[:onlineMeetingProvider] = 'teamsForBusiness' if teams
587
+ end
588
+
589
+ def add_display_fields(body)
590
+ { showAs: @options[:show_as], importance: @options[:importance],
591
+ sensitivity: @options[:sensitivity] }.each { |key, val| body[key] = val if val }
592
+ apply_reminder(body)
593
+ end
594
+
595
+ def apply_reminder(body)
596
+ reminder = @options[:reminder]
597
+ return body.merge!(isReminderOn: false) if @options[:no_reminder]
598
+
599
+ body.merge!(isReminderOn: true, reminderMinutesBeforeStart: reminder) if reminder
600
+ end
601
+ end
602
+
603
+ # Validates create option values before building the API request
604
+ module CalCreateValidation
605
+ private
606
+
607
+ def validate_create_options
608
+ validate_conflicting_flags ||
609
+ validate_enum(:show_as, CalOptionDefs::SHOW_AS_VALUES) ||
610
+ validate_enum(:importance, CalOptionDefs::IMPORTANCE_VALUES) ||
611
+ validate_enum(:sensitivity, CalOptionDefs::SENSITIVITY_VALUES) ||
612
+ validate_positive_reminder
613
+ end
614
+
615
+ def validate_conflicting_flags
616
+ conflict_error('--teams', '--no-teams') if @options[:teams] && @options[:no_teams]
617
+ end
618
+
619
+ def conflict_error(flag_a, flag_b)
620
+ error("Cannot use #{flag_a} and #{flag_b} together")
621
+ 1
622
+ end
623
+
624
+ def validate_enum(option, valid_values)
625
+ value = @options[option]
626
+ return unless value && !valid_values.include?(value)
627
+
628
+ error("Invalid --#{option.to_s.tr('_', '-')} value: #{value}. Valid: #{valid_values.join(', ')}")
629
+ 1
630
+ end
631
+
632
+ def validate_positive_reminder
633
+ reminder = @options[:reminder]
634
+ return unless reminder && !reminder.positive?
635
+
636
+ error('Reminder must be a positive number of minutes')
637
+ 1
638
+ end
639
+ end
640
+
641
+ # Event creation subcommand
642
+ module CalCreateActions
643
+ include CalTimeParsing
644
+ include CalEventFields
645
+ include CalCreateValidation
646
+
647
+ private
648
+
649
+ def create_event
650
+ return missing_subject_error unless @create_subject
651
+
652
+ validated_create_event
653
+ rescue ApiError => e
654
+ api_error_result('Failed to create event', e)
655
+ end
656
+
657
+ def validated_create_event
658
+ validation = validate_create_options
659
+ return validation if validation
660
+
661
+ times = resolve_create_times
662
+ return times if times.is_a?(Integer)
663
+
664
+ send_create_request(*times)
665
+ end
666
+
667
+ def missing_subject_error
668
+ error('Event title required. Usage: teems cal create "Title" --start TIME')
669
+ 1
670
+ end
671
+
672
+ def resolve_create_times
673
+ @options[:all_day] ? resolve_all_day_times : resolve_timed_event_times
674
+ end
675
+
676
+ def resolve_all_day_times
677
+ date = @options[:date] || Date.today.to_s
678
+ parsed = Date.parse(date)
679
+ [parsed.strftime('%Y-%m-%dT00:00:00'), (parsed + 1).strftime('%Y-%m-%dT00:00:00')]
680
+ rescue Date::Error
681
+ error("Invalid date: #{date}") || 1
682
+ end
683
+
684
+ def resolve_timed_event_times
685
+ start_time = validated_start_time
686
+ return start_time if start_time.is_a?(Integer)
687
+
688
+ end_time = compute_end_time(start_time)
689
+ return end_time if end_time.is_a?(Integer)
690
+
691
+ [format_time(start_time), format_time(end_time)]
692
+ end
693
+
694
+ def validated_start_time
695
+ start_input = @options[:start]
696
+ return missing_start_time_error unless start_input
697
+
698
+ parse_time_input(start_input) || (error("Invalid start time: #{start_input}") || 1)
699
+ end
700
+
701
+ def missing_start_time_error
702
+ error('Start time required. Use --start "YYYY-MM-DD HH:MM" or --start "today HH:MM"')
703
+ 1
704
+ end
705
+
706
+ def compute_end_time(start_time)
707
+ @options[:end] ? parse_explicit_end_time : compute_end_from_duration(start_time)
708
+ end
709
+
710
+ def parse_explicit_end_time
711
+ end_input = @options[:end]
712
+ parse_time_input(end_input) || (error("Invalid end time: #{end_input}") || 1)
713
+ end
714
+
715
+ def compute_end_from_duration(start_time)
716
+ duration = @options[:duration] || 30
717
+ return error('Duration must be a positive number of minutes') || 1 unless duration.positive?
718
+
719
+ start_time + (duration * 60)
720
+ end
721
+
722
+ def format_time(time) = time.strftime('%Y-%m-%dT%H:%M:%S')
723
+
724
+ def send_create_request(start_dt, end_dt)
725
+ event = with_token_refresh do
726
+ runner.calendar_api.create_event(build_create_event(start_dt, end_dt))
727
+ end
728
+ display_create_result(event)
729
+ 0
730
+ end
731
+
732
+ def build_create_event(start_dt, end_dt)
733
+ tz = detect_timezone
734
+ body = { subject: @create_subject,
735
+ start: { dateTime: start_dt, timeZone: tz },
736
+ end: { dateTime: end_dt, timeZone: tz },
737
+ isAllDay: @options[:all_day] || false,
738
+ transactionId: SecureRandom.uuid }
739
+ add_optional_event_fields(body)
740
+ end
741
+
742
+ def display_create_result(event)
743
+ success("Created: \"#{event.subject}\"")
744
+ warn_auto_teams(event)
745
+ event.create_summary_lines.each { |line| puts " #{line}" }
746
+ end
747
+
748
+ def warn_auto_teams(event)
749
+ return unless @options[:no_teams] && event.online_meeting_url
750
+
751
+ output.warn('Note: Teams meeting was auto-added by your org settings.')
752
+ output.warn('Disable in Outlook: Settings > Calendar > Events > "Add online meeting to all meetings"')
753
+ end
754
+ end
755
+
756
+ # Option definitions and validation for the cal command
757
+ module CalOptionDefs
758
+ SHOW_AS_VALUES = %w[free tentative busy oof workingElsewhere].freeze
759
+ IMPORTANCE_VALUES = %w[low normal high].freeze
760
+ SENSITIVITY_VALUES = %w[normal personal private confidential].freeze
761
+ SPLIT_EMAILS = ->(raw) { raw ? raw.split(',').map(&:strip).reject(&:empty?) : [] }
762
+ ALL = {
763
+ '--days' => ->(opts, args) { opts[:days] = args.shift.to_i },
764
+ '--week' => ->(opts, _args) { opts[:week] = true },
765
+ '--date' => ->(opts, args) { opts[:date] = args.shift },
766
+ '--no-interactive' => ->(opts, _args) { opts[:no_interactive] = true },
767
+ '--comment' => ->(opts, args) { opts[:comment] = args.shift },
768
+ '--no-send' => ->(opts, _args) { opts[:no_send] = true },
769
+ '--start' => ->(opts, args) { opts[:start] = args.shift },
770
+ '--end' => ->(opts, args) { opts[:end] = args.shift },
771
+ '--duration' => ->(opts, args) { opts[:duration] = args.shift.to_i },
772
+ '--all-day' => ->(opts, _args) { opts[:all_day] = true },
773
+ '--location' => ->(opts, args) { opts[:location] = args.shift },
774
+ '--body' => ->(opts, args) { opts[:body] = args.shift },
775
+ '--html' => ->(opts, args) { opts[:html] = args.shift },
776
+ '--attendees' => ->(opts, args) { opts[:attendees] = SPLIT_EMAILS.call(args.shift) },
777
+ '--optional' => ->(opts, args) { opts[:optional] = SPLIT_EMAILS.call(args.shift) },
778
+ '--room' => ->(opts, args) { opts[:rooms] = SPLIT_EMAILS.call(args.shift) },
779
+ '--teams' => ->(opts, _args) { opts[:teams] = true },
780
+ '--no-teams' => ->(opts, _args) { opts[:no_teams] = true },
781
+ '--show-as' => ->(opts, args) { opts[:show_as] = args.shift },
782
+ '--importance' => ->(opts, args) { opts[:importance] = args.shift },
783
+ '--sensitivity' => ->(opts, args) { opts[:sensitivity] = args.shift },
784
+ '--reminder' => ->(opts, args) { opts[:reminder] = args.shift.to_i },
785
+ '--no-reminder' => ->(opts, _args) { opts[:no_reminder] = true },
786
+ '--no-rsvp' => ->(opts, _args) { opts[:no_rsvp] = true },
787
+ '--no-time-proposals' => ->(opts, _args) { opts[:no_time_proposals] = true },
788
+ '--hide-attendees' => ->(opts, _args) { opts[:hide_attendees] = true }
789
+ }.freeze
790
+ end
791
+
792
+ # List calendar events and view event details
793
+ class Cal < Base
794
+ include CalDateRange
795
+ include CalSubcommandParser
796
+ include CalEventResolver
797
+ include CalEventActions
798
+ include CalCreateActions
799
+ include CalInteractiveMode
800
+
801
+ def initialize(args, runner:)
802
+ @options = {}
803
+ @subcommand = nil
804
+ @event_ref = nil
805
+ @create_subject = nil
806
+ super
807
+ end
808
+
809
+ def execute
810
+ result = validate_options
811
+ return result if result
812
+
813
+ auth_result = require_auth
814
+ return auth_result if auth_result
815
+
816
+ dispatch_subcommand
817
+ end
818
+
819
+ protected
820
+
821
+ CAL_OPTIONS = CalOptionDefs::ALL
822
+
823
+ def handle_option(arg, pending)
824
+ handler = CAL_OPTIONS[arg]
825
+ return super unless handler
826
+
827
+ handler.call(@options, pending)
828
+ end
829
+
830
+ def help_text = CAL_HELP
831
+
832
+ private
833
+
834
+ def dispatch_subcommand
835
+ case @subcommand
836
+ when 'show' then show_event
837
+ when 'create' then create_event
838
+ when 'delete' then delete_event
839
+ when 'accept', 'decline', 'tentative' then rsvp_event
840
+ else list_events
841
+ end
842
+ end
843
+
844
+ def list_events
845
+ range = compute_date_range
846
+ return error("Invalid date: #{@options[:date]}") && 1 unless range
847
+
848
+ fetch_and_display_events(range)
849
+ rescue ApiError => e
850
+ api_error_result('Failed to fetch calendar', e)
851
+ end
852
+
853
+ def fetch_and_display_events(range)
854
+ events = fetch_events(range)
855
+ return 0 if events.empty? && (puts('No events found') || true)
856
+
857
+ render_events(events)
858
+ run_interactive_loop(events) if interactive?
859
+ 0
860
+ end
861
+
862
+ def run_interactive_loop(events)
863
+ catch(:interactive_quit) { interactive_event_loop(events) }
864
+ end
865
+
866
+ def fetch_events(range)
867
+ start_dt, end_dt = range
868
+ with_token_refresh do
869
+ runner.calendar_api.list_events(
870
+ time_range: { start_dt: start_dt, end_dt: end_dt, timezone: detect_timezone },
871
+ top: @options[:limit]
872
+ )
873
+ end
874
+ end
875
+
876
+ def render_events(events)
877
+ if @options[:json]
878
+ output_json(events.map { |event| event_to_hash(event) })
879
+ else
880
+ render_events_text(events)
881
+ end
882
+ end
883
+
884
+ def render_events_text(events)
885
+ formatter = Formatters::CalendarFormatter.new(output: output)
886
+ method = @options[:verbose] ? :format_event_list_verbose : :format_event_list_compact
887
+ puts formatter.public_send(method, events)
888
+ end
889
+ end
890
+ end
891
+ end