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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/teems/commands/cal.rb +1 -51
- data/lib/teems/commands/meeting.rb +26 -9
- data/lib/teems/commands/meeting_recording.rb +72 -37
- data/lib/teems/commands/meeting_transcript.rb +31 -2
- data/lib/teems/commands/ooo.rb +123 -47
- data/lib/teems/support/time_parsing.rb +55 -0
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b2001d1f6e7653df296a0443a0a2f2bec80068ca05b4693bfd2b74c5f6a5240
|
|
4
|
+
data.tar.gz: 4f8e3920b836feb38237cd828a11ab63589855391f21cbea90eee6a54a34be9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/teems/commands/cal.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
292
|
+
parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity]) }
|
|
284
293
|
end
|
|
285
294
|
|
|
286
|
-
def format_participant_line(name, identity
|
|
295
|
+
def format_participant_line(name, identity)
|
|
287
296
|
resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
|
|
288
|
-
" #{resolved}
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/teems/commands/ooo.rb
CHANGED
|
@@ -15,9 +15,14 @@ module Teems
|
|
|
15
15
|
|
|
16
16
|
ON OPTIONS:
|
|
17
17
|
--message TEXT Auto-reply and status message
|
|
18
|
-
--start
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
265
|
-
settings[:scheduledStartDateTime] = { dateTime:
|
|
266
|
-
settings[:scheduledEndDateTime] = { dateTime:
|
|
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:
|
|
272
|
-
end:
|
|
273
|
-
isAllDay:
|
|
274
|
-
|
|
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
data/lib/teems.rb
CHANGED
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.
|
|
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:
|
|
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: []
|