teems 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 644504d1f8d334a4ddec54ef8f099424d1251dd6d15beef3c1b713da93f0ce0e
4
- data.tar.gz: 9c93db615cbe58642f127856e6c70d8b9cd0eefd81625b666cdfab736095a4cd
3
+ metadata.gz: 6b2001d1f6e7653df296a0443a0a2f2bec80068ca05b4693bfd2b74c5f6a5240
4
+ data.tar.gz: 4f8e3920b836feb38237cd828a11ab63589855391f21cbea90eee6a54a34be9f
5
5
  SHA512:
6
- metadata.gz: b72dbf0beb2fc14d5af939699526bcc060f7dab85afd9ee4f49d7fa5272add46fcda9f3a345afd67db81e8bc01ce40ca0e3b739d6dfed65d279be0ecb4172d8d
7
- data.tar.gz: 537ef91ebb3e91424172d4c3e17b8fb884cadccb5c670deaa62ee41737ffef59f650edde0cb068a81b4271983ac94fcc05befd82cd0cb1d6077803c8a488f817
6
+ metadata.gz: 245ff54545cf04f3d0db0062ab51ae2e1dc001a474c45c05e7e7cde21e8a00a523813b5019b96acaba0665010b6a6d31b7922141cb01a4203a2850f10fe5ad56
7
+ data.tar.gz: dffaa23979af2e85fffb8b0d3b8b3d8688aee1ff7c6d53eeeb5c151c81836380e93921eb5c37e2f2acf7d633a2f73e02a007e0c70af59a01e68f0bcb31a87f99
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-04-23
4
+
5
+ ### Added
6
+ - `teems meeting --audio` - Download audio-only M4A alongside or instead of video (ideal for transcription)
7
+ - `teems meeting --no-video` - Skip the video download for audio-only output
8
+ - `teems ooo` now supports timed schedules via `--start`/`--end` (e.g., "today 14:00") in addition to all-day dates
9
+ - `teems ooo --invite` - Override the configured notify list for a single invocation
10
+
11
+ ### Changed
12
+ - Recording, audio, and transcript files share a common base name derived from the SharePoint file (e.g., `2026-01-20 - Team Sync.mp4`/`.m4a`/`.vtt`)
13
+
14
+ ### Fixed
15
+ - `teems meeting` shows call duration once on the event header instead of repeating it per participant
16
+
17
+ ## [0.2.0] - 2026-04-14
18
+
19
+ ### Added
20
+ - `teems meeting` - View meeting details, download transcripts, and download recordings
21
+ - Auto-authentication when tokens are missing or expired (no manual `auth login` needed)
22
+
23
+ ### Fixed
24
+ - `teems cal delete` now uses configured endpoints instead of hardcoded defaults
25
+
3
26
  ## [0.1.0] - 2026-04-10
4
27
 
5
28
  ### Added
@@ -477,56 +477,6 @@ module Teems
477
477
  end
478
478
  end
479
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
480
  # Builds the event body hash fields for create requests
531
481
  module CalEventFields
532
482
  private
@@ -640,7 +590,7 @@ module Teems
640
590
 
641
591
  # Event creation subcommand
642
592
  module CalCreateActions
643
- include CalTimeParsing
593
+ include Support::TimeParsing
644
594
  include CalEventFields
645
595
  include CalCreateValidation
646
596
 
@@ -19,6 +19,8 @@ module Teems
19
19
  --transcript Download meeting transcript (WebVTT)
20
20
  --recording Download meeting recording (MP4, requires ffmpeg)
21
21
  Combine with --transcript to embed subtitles
22
+ --audio Also save a separate audio file (M4A) — ideal for transcription
23
+ --no-video Skip the video file; with --audio produces audio-only output
22
24
  --chat Show meeting chat messages
23
25
  -o, --output-dir Directory for downloads (default: current directory)
24
26
  -v, --verbose Show debug output
@@ -30,6 +32,8 @@ module Teems
30
32
  teems meeting 19:meeting_abc123@thread.v2
31
33
  teems meeting 19:meeting_abc123@thread.v2 --chat
32
34
  teems meeting 19:meeting_abc123@thread.v2 --transcript
35
+ teems meeting 19:meeting_abc123@thread.v2 --audio -o ~/Downloads # video + audio
36
+ teems meeting 19:meeting_abc123@thread.v2 --audio --no-video -o ~/Downloads # audio only
33
37
  teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
34
38
  teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
35
39
  teems meeting AAMkAGVmMDEz... # By calendar event ID
@@ -40,6 +44,8 @@ module Teems
40
44
  ALL = {
41
45
  '--transcript' => ->(opts, _args) { opts[:transcript] = true },
42
46
  '--recording' => ->(opts, _args) { opts[:recording] = true },
47
+ '--audio' => ->(opts, _args) { opts[:audio] = true },
48
+ '--no-video' => ->(opts, _args) { opts[:no_video] = true },
43
49
  '--chat' => ->(opts, _args) { opts[:chat] = true },
44
50
  '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
45
51
  '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
@@ -187,10 +193,12 @@ module Teems
187
193
 
188
194
  def parse_call_event(msg)
189
195
  content = msg['content'].to_s
196
+ parts = extract_partlist(content)
190
197
  build_msg_hash(msg).merge(
191
198
  call_id: content.match(CALLID_RE)&.captures&.first,
192
199
  ical_uid: content.match(INSTANCE_ICAL_RE)&.captures&.first,
193
- participants: extract_partlist(content)
200
+ duration: parts.first&.dig(:duration),
201
+ participants: parts
194
202
  )
195
203
  end
196
204
 
@@ -272,7 +280,8 @@ module Teems
272
280
 
273
281
  def display_single_call_event(event)
274
282
  puts
275
- puts " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
283
+ header = " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
284
+ puts "#{header} (#{format_call_duration(event[:duration])})"
276
285
  display_participants(event[:participants])
277
286
  end
278
287
 
@@ -280,12 +289,12 @@ module Teems
280
289
  return if parts.empty?
281
290
 
282
291
  puts " Participants (#{parts.length}):"
283
- parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity], entry[:duration]) }
292
+ parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity]) }
284
293
  end
285
294
 
286
- def format_participant_line(name, identity, duration)
295
+ def format_participant_line(name, identity)
287
296
  resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
288
- " #{resolved} (#{format_call_duration(duration)})"
297
+ " #{resolved}"
289
298
  end
290
299
 
291
300
  def needs_resolution?(name)
@@ -452,19 +461,27 @@ module Teems
452
461
  display_meeting_chat(classified[:chat_messages])
453
462
  return 0
454
463
  end
455
- return download_recording_with_transcript(target, classified) if @options[:recording]
464
+ media = media_output_spec
465
+ return download_media_with_transcript(target, classified, media) if media
456
466
  return download_transcript(target, classified) || 0 if @options[:transcript]
457
467
 
458
468
  display_meeting_summary(target, classified)
459
469
  0
460
470
  end
461
471
 
462
- def download_recording_with_transcript(target, classified)
472
+ def media_output_spec
473
+ audio, recording, no_video = @options.values_at(:audio, :recording, :no_video)
474
+ return nil unless audio || recording || no_video
475
+
476
+ { video: (recording || audio) && !no_video, audio: audio || no_video }
477
+ end
478
+
479
+ def download_media_with_transcript(target, classified, media)
463
480
  if @options[:transcript]
464
481
  result = download_transcript(target, classified)
465
- warn('Transcript download failed; proceeding with recording') if result == 1
482
+ warn("Transcript download failed; proceeding with #{media[:video] ? 'recording' : 'audio'}") if result == 1
466
483
  end
467
- download_recording(target, classified) || 0
484
+ download_recording(target, classified, media: media) || 0
468
485
  end
469
486
  end
470
487
  end
@@ -176,32 +176,86 @@ module Teems
176
176
  end
177
177
  end
178
178
 
179
+ # Writes video/audio output files from downloaded DASH tracks via ffmpeg
180
+ module RecordingOutputWriter
181
+ private
182
+
183
+ def produce_outputs(video_path, audio_path, media)
184
+ video_result = media[:video] ? write_video_output(video_path, audio_path) : true
185
+ audio_result = media[:audio] ? write_audio_output(audio_path) : true
186
+ FileUtils.rm_f([video_path, audio_path].compact)
187
+ video_result && audio_result ? 0 : 1
188
+ end
189
+
190
+ def write_video_output(video_path, audio_path)
191
+ output_path = File.join(@rec_dir, "#{@recording_stem}.mp4")
192
+ info('Merging video and audio tracks...')
193
+ merged = run_ffmpeg('-i', video_path, '-i', audio_path, '-c', 'copy', output_path)
194
+ return error('ffmpeg merge failed') && false unless merged
195
+
196
+ embed_subtitle_if_available(output_path)
197
+ success("Recording saved to #{output_path}")
198
+ true
199
+ end
200
+
201
+ def write_audio_output(audio_path)
202
+ output_path = File.join(@rec_dir, "#{@recording_stem}.m4a")
203
+ info('Remuxing audio track...')
204
+ remuxed = run_ffmpeg('-i', audio_path, '-c', 'copy', output_path)
205
+ return error('ffmpeg audio remux failed') && false unless remuxed
206
+
207
+ success("Audio saved to #{output_path}")
208
+ true
209
+ end
210
+
211
+ def embed_subtitle_if_available(video_path)
212
+ vtt_path = paired_vtt_path(video_path)
213
+ return unless vtt_path
214
+
215
+ info('Embedding transcript as subtitle track...')
216
+ final_path = video_path.sub('.mp4', '_subs.mp4')
217
+ return unless run_ffmpeg('-i', video_path, '-i', vtt_path, '-c', 'copy', '-c:s', 'mov_text', final_path)
218
+
219
+ FileUtils.mv(final_path, video_path)
220
+ end
221
+
222
+ def paired_vtt_path(video_path)
223
+ paired = video_path.sub(/\.mp4\z/, '.vtt')
224
+ return paired if File.exist?(paired)
225
+
226
+ Dir.glob(File.join(File.dirname(video_path), '*.vtt')).first
227
+ end
228
+ end
229
+
179
230
  # Downloads meeting recordings via DASH streaming from SharePoint
180
231
  module MeetingRecording
232
+ include MeetingFilename
181
233
  include RecordingResolver
182
234
  include SegmentDownloader
235
+ include RecordingOutputWriter
183
236
 
184
237
  MANIFEST_PARAMS = 'format=dash&part=index'
185
238
 
186
239
  private
187
240
 
188
- def download_recording(target, classified)
241
+ def download_recording(target, classified, media: { video: true, audio: false })
189
242
  sharing_url = target[:fileUrl] || classified[:recordings].filter_map { |rec| rec[:url] }.first
190
243
  return error('No recordings found for this meeting') unless sharing_url
191
244
  return error('ffmpeg is required. Install with: brew install ffmpeg') unless ffmpeg?
192
245
 
193
- execute_recording_pipeline(sharing_url)
246
+ execute_recording_pipeline(sharing_url, media)
194
247
  end
195
248
 
196
- def execute_recording_pipeline(sharing_url)
197
- info('Fetching recording via SharePoint...')
249
+ def execute_recording_pipeline(sharing_url, media)
250
+ info("Fetching #{media[:video] ? 'recording' : 'audio'} via SharePoint...")
198
251
  file_info = resolve_recording_file_info(sharing_url)
199
252
  return 1 unless file_info
200
253
 
254
+ @recording_stem = derive_output_stem(file_info[:name])
201
255
  manifest = fetch_dash_manifest(file_info[:transform_url])
202
256
  return error('Could not fetch DASH manifest') unless manifest
203
257
 
204
- download_and_assemble(manifest)
258
+ download_and_assemble(manifest, media)
205
259
  end
206
260
 
207
261
  def fetch_dash_manifest(transform_url)
@@ -227,29 +281,33 @@ module Teems
227
281
  nil
228
282
  end
229
283
 
230
- def download_and_assemble(manifest_xml)
284
+ def download_and_assemble(manifest_xml, media)
231
285
  tracks = DashManifestParser.new(manifest_xml).parse
232
- selected = select_tracks(tracks)
286
+ selected = select_tracks(tracks, media)
233
287
  return error('No video/audio tracks found in manifest') unless selected
234
288
 
235
- assemble_recording(selected, manifest_xml)
289
+ assemble_recording(selected, manifest_xml, media)
236
290
  end
237
291
 
238
- def select_tracks(tracks)
292
+ def select_tracks(tracks, media)
239
293
  grouped = tracks.group_by(&:type)
240
- video = grouped['video']&.first
241
294
  audio = grouped['audio']&.first
242
- video && audio ? { video: video, audio: audio } : nil
295
+ return nil unless audio
296
+
297
+ video = grouped['video']&.first
298
+ return nil if media[:video] && !video
299
+
300
+ { video: video, audio: audio }
243
301
  end
244
302
 
245
- def assemble_recording(selected, manifest_xml)
303
+ def assemble_recording(selected, manifest_xml, media)
246
304
  @rec_dir = @options[:output_dir] || '.'
247
305
  FileUtils.mkdir_p(@rec_dir)
248
306
  @rec_base_url = manifest_xml.match(%r{<BaseURL>([^<]+)</BaseURL>})&.then { _1[1] } || ''
249
307
 
250
- video_path = write_track(selected[:video], 'video')
251
308
  audio_path = write_track(selected[:audio], 'audio')
252
- merge_and_finalize(video_path, audio_path, @rec_dir)
309
+ video_path = media[:video] ? write_track(selected[:video], 'video') : nil
310
+ produce_outputs(video_path, audio_path, media)
253
311
  end
254
312
 
255
313
  def write_track(track, label)
@@ -261,29 +319,6 @@ module Teems
261
319
  path
262
320
  end
263
321
 
264
- def merge_and_finalize(video_path, audio_path, dir)
265
- output_path = File.join(dir, 'recording.mp4')
266
- info('Merging video and audio tracks...')
267
- merged = run_ffmpeg('-i', video_path, '-i', audio_path, '-c', 'copy', output_path)
268
- FileUtils.rm_f([video_path, audio_path])
269
-
270
- return error('ffmpeg merge failed') unless merged
271
-
272
- embed_subtitle_if_available(output_path)
273
- success("Recording saved to #{output_path}")
274
- end
275
-
276
- def embed_subtitle_if_available(video_path)
277
- vtt_path = Dir.glob(File.join(File.dirname(video_path), '*.vtt')).first
278
- return unless vtt_path
279
-
280
- info('Embedding transcript as subtitle track...')
281
- final_path = video_path.sub('.mp4', '_subs.mp4')
282
- return unless run_ffmpeg('-i', video_path, '-i', vtt_path, '-c', 'copy', '-c:s', 'mov_text', final_path)
283
-
284
- FileUtils.mv(final_path, video_path)
285
- end
286
-
287
322
  def run_ffmpeg(*)
288
323
  system('ffmpeg', '-y', *, out: File::NULL, err: File::NULL)
289
324
  end
@@ -2,6 +2,34 @@
2
2
 
3
3
  module Teems
4
4
  module Commands
5
+ # Derives a human-friendly output filename stem from a SharePoint recording/transcript name.
6
+ # SharePoint format is typically "<subject>-YYYYMMDD_HHMMSS[-Meeting Recording].<ext>"
7
+ # which we reshape into "YYYY-MM-DD - <subject>".
8
+ module MeetingFilename
9
+ SHAREPOINT_NAME_RE = /\A(.+)-(\d{4})(\d{2})(\d{2})_\d{6}\z/
10
+ UNSAFE_FILENAME_CHARS = %r{[/\\:*?"<>|\x00]}
11
+
12
+ private
13
+
14
+ def derive_output_stem(sharepoint_name)
15
+ base = File.basename(sharepoint_name.to_s, '.*').sub(/-Meeting Recording\z/, '')
16
+ sanitize_filename(reshape_sharepoint_name(base))
17
+ end
18
+
19
+ def reshape_sharepoint_name(base)
20
+ captures = base.match(SHAREPOINT_NAME_RE)&.captures
21
+ return base unless captures
22
+
23
+ subject, year, month, day = captures
24
+ "#{year}-#{month}-#{day} - #{subject}"
25
+ end
26
+
27
+ def sanitize_filename(name)
28
+ cleaned = name.gsub(UNSAFE_FILENAME_CHARS, '_').strip
29
+ cleaned.empty? ? 'recording' : cleaned
30
+ end
31
+ end
32
+
5
33
  # Parses SharePoint embed page HTML to extract file metadata
6
34
  module EmbedPageParser
7
35
  SP_ITEM_RE = %r{(https://[^/]+(?::\d+)?/.+?)/_api/v2\.\d+/drives/([^/]+)/items/([^/?]+)}
@@ -113,6 +141,7 @@ module Teems
113
141
  # Downloads meeting transcripts via SharePoint API (no Safari required)
114
142
  module MeetingTranscript
115
143
  include EmbedPageParser
144
+ include MeetingFilename
116
145
 
117
146
  private
118
147
 
@@ -196,11 +225,11 @@ module Teems
196
225
  url ? { url: url, name: File.basename(entry['name'] || 'transcript.vtt') } : nil
197
226
  end
198
227
 
228
+ # Prefers the recording's name (via @transcript_info) so transcript and recording share a stem.
199
229
  def save_transcript(transcript)
200
230
  dir = @options[:output_dir] || '.'
201
231
  FileUtils.mkdir_p(dir)
202
- path = File.join(dir, File.basename(transcript[:name]))
203
-
232
+ path = File.join(dir, "#{derive_output_stem(@transcript_info[:name] || transcript[:name])}.vtt")
204
233
  info("Downloading transcript to #{path}...")
205
234
  vtt = fetch_and_convert_transcript(transcript[:url])
206
235
  return error('Failed to download transcript content') unless vtt
@@ -15,9 +15,14 @@ module Teems
15
15
 
16
16
  ON OPTIONS:
17
17
  --message TEXT Auto-reply and status message
18
- --start DATE Schedule start (YYYY-MM-DD), enables scheduled mode
19
- --end DATE Schedule end (YYYY-MM-DD), required with --start
20
- --event Create an all-day OOO calendar event for notify list
18
+ --start TIME Schedule start; enables scheduled mode. Formats:
19
+ YYYY-MM-DD (all-day)
20
+ YYYY-MM-DD HH:MM (timed)
21
+ "today HH:MM", "tomorrow HH:MM", HH:MM
22
+ --end TIME Schedule end, required with --start.
23
+ Must match --start style (date or timed).
24
+ --event Create an OOO calendar event for notify list
25
+ --invite EMAILS Comma-separated invitees; overrides config notify list
21
26
  --no-status Skip setting Teams status/presence
22
27
 
23
28
  CONFIGURATION:
@@ -38,10 +43,43 @@ module Teems
38
43
  teems ooo on # Enable OOO (always on)
39
44
  teems ooo on --message "Vacation" # Custom message
40
45
  teems ooo on --start 2026-04-14 --end 2026-04-18
46
+ teems ooo on --start "today 14:00" --end "today 17:00" \\
47
+ --message "Dr appt"
41
48
  teems ooo off # Disable OOO
42
49
  teems ooo config # Show config
43
50
  HELP
44
51
 
52
+ # A parsed --start or --end value. Produces ISO datetime strings for
53
+ # auto-reply schedules and calendar events. Kind is :timed, :date, or
54
+ # :unscheduled; value is a Time, Date, or nil respectively.
55
+ OooScheduleEntry = Data.define(:kind, :value) do
56
+ def timed? = kind == :timed
57
+ def all_day? = kind == :date
58
+ def unscheduled? = kind == :unscheduled
59
+
60
+ def reply_start_iso
61
+ timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T00:00:00"
62
+ end
63
+
64
+ def reply_end_iso
65
+ timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T23:59:59"
66
+ end
67
+
68
+ def event_start_iso
69
+ base = value || Date.today
70
+ timed? ? base.strftime('%Y-%m-%dT%H:%M:%S') : "#{base}T00:00:00"
71
+ end
72
+
73
+ def event_end_iso
74
+ base = value || Date.today
75
+ return base.strftime('%Y-%m-%dT%H:%M:%S') if timed?
76
+
77
+ "#{base + 1}T00:00:00"
78
+ end
79
+ end
80
+
81
+ UNSCHEDULED_OOO_SCHEDULE = OooScheduleEntry.new(kind: :unscheduled, value: nil)
82
+
45
83
  # Displays current OOO status (auto-reply + presence)
46
84
  module OooDisplay
47
85
  private
@@ -90,13 +128,20 @@ module Teems
90
128
  end
91
129
 
92
130
  def render_schedule(replies)
93
- start_dt = replies.dig('scheduledStartDateTime', 'dateTime')
94
- end_dt = replies.dig('scheduledEndDateTime', 'dateTime')
95
- puts " Schedule: #{format_dt(start_dt)} to #{format_dt(end_dt)}" if start_dt
131
+ start_entry = replies['scheduledStartDateTime']
132
+ return unless start_entry
133
+
134
+ start_str = format_schedule_dt(start_entry)
135
+ end_str = format_schedule_dt(replies['scheduledEndDateTime'])
136
+ puts " Schedule: #{start_str} to #{end_str} #{short_tz_label}"
96
137
  end
97
138
 
98
- def format_dt(raw)
99
- Time.parse(raw).strftime('%Y-%m-%d %H:%M')
139
+ def format_schedule_dt(entry)
140
+ return '' unless entry
141
+
142
+ raw = entry['dateTime']
143
+ time = entry['timeZone'] == 'UTC' ? Time.parse("#{raw} UTC").getlocal : Time.parse(raw)
144
+ time.strftime('%Y-%m-%d %H:%M')
100
145
  rescue ArgumentError
101
146
  raw.to_s
102
147
  end
@@ -118,6 +163,60 @@ module Teems
118
163
  end
119
164
  end
120
165
 
166
+ # Parses and validates --start/--end into @schedule_start and @schedule_end.
167
+ # Each parsed value is a tuple: [:date, Date] for all-day or [:timed, Time] for timed.
168
+ module OooSchedule
169
+ private
170
+
171
+ def validate_create_options
172
+ start_raw = @options[:start]
173
+ end_raw = @options[:end]
174
+ return unless start_raw
175
+ return missing_end_error unless end_raw
176
+
177
+ parse_schedule_pair(start_raw, end_raw)
178
+ end
179
+
180
+ def missing_end_error
181
+ error('--end is required when using --start')
182
+ 1
183
+ end
184
+
185
+ def parse_schedule_pair(start_raw, end_raw)
186
+ @schedule_start = parse_schedule_value(start_raw)
187
+ return invalid_schedule_error(:start, start_raw) unless @schedule_start
188
+
189
+ @schedule_end = parse_schedule_value(end_raw)
190
+ return invalid_schedule_error(:end, end_raw) unless @schedule_end
191
+
192
+ return mixed_schedule_error if schedule_kind(@schedule_start) != schedule_kind(@schedule_end)
193
+
194
+ nil
195
+ end
196
+
197
+ def parse_schedule_value(raw)
198
+ time = parse_time_input(raw)
199
+ return OooScheduleEntry.new(kind: :timed, value: time) if time
200
+
201
+ OooScheduleEntry.new(kind: :date, value: Date.parse(raw))
202
+ rescue Date::Error
203
+ nil
204
+ end
205
+
206
+ def invalid_schedule_error(key, raw)
207
+ error("Invalid value for --#{key}: #{raw}. " \
208
+ "Use YYYY-MM-DD, HH:MM, 'today HH:MM', 'tomorrow HH:MM', or 'YYYY-MM-DD HH:MM'")
209
+ 1
210
+ end
211
+
212
+ def mixed_schedule_error
213
+ error('--start and --end must both be dates or both include a time')
214
+ 1
215
+ end
216
+
217
+ def schedule_kind(parsed) = parsed.kind
218
+ end
219
+
121
220
  # Enables/disables OOO: auto-reply, status message, presence, calendar event
122
221
  module OooActions
123
222
  private
@@ -139,28 +238,6 @@ module Teems
139
238
  results.values.all? ? 0 : 1
140
239
  end
141
240
 
142
- def validate_create_options
143
- validate_schedule_dates || validate_date_format(:start) || validate_date_format(:end)
144
- end
145
-
146
- def validate_schedule_dates
147
- return unless @options[:start] && !@options[:end]
148
-
149
- error('--end is required when using --start')
150
- 1
151
- end
152
-
153
- def validate_date_format(key)
154
- value = @options[key]
155
- return unless value
156
-
157
- Date.parse(value)
158
- nil
159
- rescue ArgumentError
160
- error("Invalid date for --#{key}: #{value}. Expected format: YYYY-MM-DD")
161
- 1
162
- end
163
-
164
241
  def execute_ooo_steps
165
242
  results = {}
166
243
  results[:auto_reply] = set_auto_reply
@@ -261,37 +338,29 @@ module Teems
261
338
  end
262
339
 
263
340
  def add_schedule(settings)
264
- tz = detect_timezone
265
- settings[:scheduledStartDateTime] = { dateTime: "#{@options[:start]}T00:00:00", timeZone: tz }
266
- settings[:scheduledEndDateTime] = { dateTime: "#{@options[:end]}T23:59:59", timeZone: tz }
341
+ timezone = detect_timezone
342
+ settings[:scheduledStartDateTime] = { dateTime: @schedule_start.reply_start_iso, timeZone: timezone }
343
+ settings[:scheduledEndDateTime] = { dateTime: @schedule_end.reply_end_iso, timeZone: timezone }
267
344
  end
268
345
 
269
346
  def build_ooo_event
347
+ timezone = detect_timezone
270
348
  { subject: status_message_text,
271
- start: ooo_event_time(:start),
272
- end: ooo_event_time(:end),
273
- isAllDay: true,
274
- showAs: 'free',
275
- isReminderOn: false,
276
- isOnlineMeeting: false,
349
+ start: { dateTime: @schedule_start.event_start_iso, timeZone: timezone },
350
+ end: { dateTime: @schedule_end.event_end_iso, timeZone: timezone },
351
+ isAllDay: !@schedule_start.timed?, showAs: 'free',
352
+ isReminderOn: false, isOnlineMeeting: false,
277
353
  transactionId: SecureRandom.uuid,
278
354
  attendees: notify_list.map { |email| ooo_attendee(email) },
279
355
  responseRequested: false }
280
356
  end
281
357
 
282
- def ooo_event_time(field)
283
- date = @options[field] || Date.today.to_s
284
- end_date = field == :end ? (Date.parse(date) + 1).to_s : date
285
- tz = detect_timezone
286
- { dateTime: "#{end_date}T00:00:00", timeZone: tz }
287
- end
288
-
289
358
  def ooo_attendee(email)
290
359
  { emailAddress: { address: email }, type: 'required' }
291
360
  end
292
361
 
293
362
  def notify_list
294
- @notify_list ||= ooo_config('notify') || []
363
+ @notify_list ||= @options[:invite] || ooo_config('notify') || []
295
364
  end
296
365
 
297
366
  def ooo_config(key)
@@ -302,20 +371,27 @@ module Teems
302
371
  # Manage out-of-office: auto-reply, status, presence, and calendar event
303
372
  class Ooo < Base
304
373
  include Support::Timezone
374
+ include Support::TimeParsing
305
375
  include OooDisplay
376
+ include OooSchedule
306
377
  include OooActions
307
378
  include OooBuildHelpers
308
379
 
309
380
  def initialize(args, runner:)
310
381
  @options = {}
382
+ @schedule_start = UNSCHEDULED_OOO_SCHEDULE
383
+ @schedule_end = UNSCHEDULED_OOO_SCHEDULE
311
384
  super
312
385
  end
313
386
 
387
+ SPLIT_OOO_EMAILS = ->(raw) { raw ? raw.split(',').map(&:strip).reject(&:empty?) : [] }
388
+
314
389
  OOO_OPTIONS = {
315
390
  '--message' => ->(opts, args) { opts[:message] = args.shift },
316
391
  '--start' => ->(opts, args) { opts[:start] = args.shift },
317
392
  '--end' => ->(opts, args) { opts[:end] = args.shift },
318
393
  '--event' => ->(opts, _args) { opts[:event] = true },
394
+ '--invite' => ->(opts, args) { opts[:invite] = SPLIT_OOO_EMAILS.call(args.shift) },
319
395
  '--no-status' => ->(opts, _args) { opts[:no_status] = true }
320
396
  }.freeze
321
397
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Support
5
+ # Shared time-input parsing for commands that accept schedule inputs
6
+ # like "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
7
+ module TimeParsing
8
+ private
9
+
10
+ def parse_time_input(raw)
11
+ date, time_str = split_time_input(raw)
12
+ return unless time_str
13
+
14
+ date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
15
+ end
16
+
17
+ def split_time_input(raw)
18
+ base = Date.today
19
+ split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
20
+ end
21
+
22
+ def split_tomorrow_time(raw, base)
23
+ return unless raw.start_with?('tomorrow ')
24
+
25
+ [base + 1, raw.delete_prefix('tomorrow ')]
26
+ end
27
+
28
+ def split_today_time(raw, base)
29
+ return unless raw.match?(/\A(?:today\s+)?\d{1,2}:\d{2}\z/)
30
+
31
+ [base, raw.delete_prefix('today ')]
32
+ end
33
+
34
+ def split_absolute_time(raw)
35
+ return unless raw.match?(/\A\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\z/)
36
+
37
+ raw.split(/\s+/, 2)
38
+ end
39
+
40
+ def parse_relative_time(date, time_str)
41
+ hour, min = time_str.split(':').map(&:to_i)
42
+ Time.new(date.year, date.month, date.day, hour, min, 0)
43
+ rescue ArgumentError
44
+ nil
45
+ end
46
+
47
+ def parse_absolute_time(date_str, time_str)
48
+ date = Date.parse(date_str)
49
+ parse_relative_time(date, time_str)
50
+ rescue Date::Error
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/teems/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teems
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/teems.rb CHANGED
@@ -116,5 +116,6 @@ module Teems
116
116
  autoload :HelpFormatter, 'teems/support/help_formatter'
117
117
  autoload :ErrorLogger, 'teems/support/error_logger'
118
118
  autoload :Timezone, 'teems/support/timezone'
119
+ autoload :TimeParsing, 'teems/support/time_parsing'
119
120
  end
120
121
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: teems
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -83,6 +83,7 @@ files:
83
83
  - lib/teems/services/token_store.rb
84
84
  - lib/teems/support/error_logger.rb
85
85
  - lib/teems/support/help_formatter.rb
86
+ - lib/teems/support/time_parsing.rb
86
87
  - lib/teems/support/timezone.rb
87
88
  - lib/teems/support/xdg_paths.rb
88
89
  - lib/teems/version.rb
@@ -109,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
110
  - !ruby/object:Gem::Version
110
111
  version: '0'
111
112
  requirements: []
112
- rubygems_version: 3.6.9
113
+ rubygems_version: 4.0.10
113
114
  specification_version: 4
114
115
  summary: A command-line interface for Microsoft Teams
115
116
  test_files: []