teems 0.3.0 → 0.3.2

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.
@@ -0,0 +1,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'date'
5
+ require 'open3'
6
+ require 'tmpdir'
7
+
8
+ module Teems
9
+ module Commands
10
+ # Fetches saved Teams transcripts for calendar meetings. Transcript retrieval still
11
+ # goes through `teems meeting`, keeping its SharePoint/auth implementation in one place.
12
+ class Transcripts < Base
13
+ HELP = <<~HELP
14
+ teems transcripts - Sync saved meeting transcripts onto this machine
15
+
16
+ USAGE:
17
+ teems transcripts sync [--since DAYS | --date YYYY-MM-DD] [--dry-run] [--no-post-sync]
18
+
19
+ OPTIONS:
20
+ --since DAYS Calendar lookback (default 7; first run 30)
21
+ --date YYYY-MM-DD Only scan one calendar date
22
+ --dry-run List meetings and recording counts without downloading
23
+ --no-post-sync Skip the configured post-sync command for this run
24
+ -q, --quiet Suppress progress (not errors)
25
+
26
+ WebVTTs: ~/.local/share/teems/transcripts/
27
+ Markdown (for local search, e.g. qmd): ~/.local/share/teems/transcripts-md/
28
+ Sync state: ~/.local/state/teems/transcript-sync.json
29
+ Only meetings on your Teams calendar with a saved, accessible recording
30
+ transcript can be retrieved; each recording's transcript is kept (a meeting
31
+ restarted mid-session has several). Does not alter meeting-capture files.
32
+
33
+ POST-SYNC COMMAND:
34
+ Set "transcripts": {"post_sync_command": "..."} in ~/.config/teems/config.json to
35
+ run a shell command (e.g. a qmd index refresh) after a sync that changed Markdown.
36
+ It receives TEEMS_TRANSCRIPTS_CHANGED (newline-separated Markdown paths),
37
+ TEEMS_TRANSCRIPTS_CHANGED_COUNT, TEEMS_TRANSCRIPTS_MARKDOWN_DIR, and
38
+ TEEMS_TRANSCRIPTS_DIR. "post_sync_timeout" (seconds, default 300) bounds it.
39
+ A failing or timed-out command is reported but does not fail the sync.
40
+ HELP
41
+
42
+ def execute
43
+ validation = validate_options
44
+ return validation if validation
45
+ unless positional_args == ['sync']
46
+ return error('Usage: teems transcripts sync [--since DAYS | --date YYYY-MM-DD]')
47
+ end
48
+ return error('--since must be between 1 and 366') unless (1..366).cover?(@options.fetch(:since, 7))
49
+ return error('Invalid --date (expected YYYY-MM-DD)') if @options[:invalid_date]
50
+
51
+ TranscriptSyncEngine.new(@options, output, hook: transcript_settings).run
52
+ end
53
+
54
+ protected
55
+
56
+ def handle_option(arg, pending)
57
+ case arg
58
+ when '--since' then @options[:since] = Integer(pending.shift, exception: false)
59
+ when '--date' then parse_date_option(pending.shift)
60
+ when '--dry-run' then @options[:dry_run] = true
61
+ when '--no-post-sync' then @options[:no_post_sync] = true
62
+ else super
63
+ end
64
+ end
65
+
66
+ def transcript_settings
67
+ settings = config['transcripts']
68
+ settings.is_a?(Hash) ? settings : {}
69
+ end
70
+
71
+ def parse_date_option(value)
72
+ @options[:date] = Date.iso8601(value)
73
+ rescue ArgumentError, TypeError
74
+ @options[:invalid_date] = true
75
+ end
76
+
77
+ def help_text = HELP
78
+ end
79
+
80
+ # Private on-disk storage, separate from meeting-capture.
81
+ module TranscriptSyncFiles
82
+ private
83
+
84
+ def prepare_private_directories
85
+ [@output_dir, @state_dir].each do |dir|
86
+ FileUtils.mkdir_p(dir, mode: 0o700)
87
+ File.chmod(0o700, dir)
88
+ end
89
+ end
90
+
91
+ def log_locked
92
+ log 'Another transcript sync is running; skipping.'
93
+ 0
94
+ end
95
+
96
+ def load_manifest
97
+ File.file?(@manifest_path) ? JSON.parse(File.read(@manifest_path)) : { 'events' => {} }
98
+ end
99
+
100
+ def downloaded?(key, manifest)
101
+ prior = manifest.fetch('events', {})[key]
102
+ prior && valid_vtt?(File.join(@output_dir, File.basename(prior.fetch('file'))))
103
+ end
104
+
105
+ def persist_download(date, event, key, file, manifest)
106
+ adopted = adopt_legacy_download(date, event, file, manifest)
107
+ target_name = adopted || store_download(file, key)
108
+ manifest['events'][key] = { 'file' => target_name, 'downloaded_at' => Time.now.iso8601,
109
+ 'date' => date.iso8601, 'subject' => event_name(event) }
110
+ save_manifest(manifest)
111
+ log "#{date}: #{adopted ? 'already had' : 'saved'} #{target_name}"
112
+ end
113
+
114
+ def store_download(file, key)
115
+ target_name = "#{File.basename(file, '.vtt')}--#{key[0, 10]}.vtt"
116
+ target = File.join(@output_dir, target_name)
117
+ File.rename(file, target) unless valid_vtt?(target)
118
+ File.chmod(0o600, target)
119
+ target_name
120
+ end
121
+
122
+ # Earlier syncs kept one transcript per event. Reuse that file when it is
123
+ # this recording's transcript instead of saving a duplicate.
124
+ def adopt_legacy_download(date, event, file, manifest)
125
+ key = legacy_key(date, event)
126
+ legacy = manifest['events'][key]
127
+ return unless legacy
128
+
129
+ existing = File.join(@output_dir, File.basename(legacy.fetch('file')))
130
+ return unless valid_vtt?(existing) && FileUtils.identical?(existing, file)
131
+
132
+ manifest['events'].delete(key)
133
+ File.basename(existing)
134
+ end
135
+
136
+ def valid_vtt?(file)
137
+ File.file?(file) && File.size(file) > 10 && File.open(file, 'rb') { |io| io.read(6) == 'WEBVTT' }
138
+ end
139
+
140
+ def valid_download?(status, files)
141
+ status.success? && files.length == 1 && valid_vtt?(files.first)
142
+ end
143
+
144
+ def save_manifest(manifest)
145
+ write_private(@manifest_path, "#{JSON.pretty_generate(manifest)}\n")
146
+ end
147
+
148
+ def write_private(path, content)
149
+ temp = "#{path}.tmp.#{$PROCESS_ID}"
150
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |file| file.write(content) }
151
+ File.rename(temp, path)
152
+ File.chmod(0o600, path)
153
+ ensure
154
+ File.delete(temp) if temp && File.exist?(temp)
155
+ end
156
+ end
157
+
158
+ # Speaker-turn Markdown copies of each WebVTT, suitable for a local search index.
159
+ module TranscriptSyncMarkdown
160
+ private
161
+
162
+ def sync_markdown(manifest)
163
+ FileUtils.mkdir_p(@markdown_dir, mode: 0o700)
164
+ File.chmod(0o700, @markdown_dir)
165
+ results = manifest.fetch('events', {}).values.map { |entry| export_markdown(entry) }
166
+ written = results.count(:written)
167
+ log "Markdown: updated #{written} transcript(s)" if written.positive?
168
+ results
169
+ end
170
+
171
+ def export_markdown(entry)
172
+ vtt = File.join(@output_dir, File.basename(entry.fetch('file')))
173
+ return :missing unless valid_vtt?(vtt)
174
+
175
+ target = File.join(@markdown_dir, "#{File.basename(vtt, '.vtt')}.md")
176
+ return :current if markdown_current?(target, vtt)
177
+
178
+ write_private(target, markdown_for(vtt, entry))
179
+ @changed_markdown << target
180
+ :written
181
+ rescue StandardError => e
182
+ markdown_failure(entry, e)
183
+ end
184
+
185
+ def markdown_current?(target, vtt) = File.file?(target) && File.mtime(target) >= File.mtime(vtt)
186
+
187
+ def markdown_failure(entry, error)
188
+ failure "Markdown export failed for #{File.basename(entry['file'].to_s)} (#{error.class}: #{error.message})"
189
+ :failed
190
+ end
191
+
192
+ def markdown_for(vtt, entry)
193
+ stem = File.basename(vtt, '.vtt').sub(/--\h{10}\z/, '')
194
+ Formatters::TranscriptMarkdown.new(
195
+ File.read(vtt, encoding: 'bom|utf-8'),
196
+ title: entry['subject'] || stem.sub(/\A\d{4}-\d{2}-\d{2} - /, '').sub(/-\d{8}_\d{6}UTC\z/, ''),
197
+ date: entry['date'] || transcript_date(stem),
198
+ source: File.basename(vtt)
199
+ ).render
200
+ end
201
+
202
+ def transcript_date(stem)
203
+ return stem[0, 10] if stem.match?(/\A\d{4}-\d{2}-\d{2} - /)
204
+
205
+ stem.match(/-(\d{4})(\d{2})(\d{2})_\d{6}UTC\z/)&.captures&.join('-')
206
+ end
207
+ end
208
+
209
+ # Calendar discovery is intentionally limited to scheduled Teams meetings.
210
+ module TranscriptSyncCalendar
211
+ private
212
+
213
+ def calendar_events(date)
214
+ stdout, stderr, status = Open3.capture3(teems_executable, 'cal', '--date', date.iso8601, '--json')
215
+ raise "teems cal failed (exit #{status.exitstatus}): #{redacted_error(stderr)}" unless status.success?
216
+
217
+ return [] if stdout.strip == 'No events found'
218
+
219
+ events = JSON.parse(stdout)
220
+ raise 'teems cal returned a non-array response' unless events.is_a?(Array)
221
+
222
+ events.select { |event| teams_event?(event) }
223
+ end
224
+
225
+ def teams_event?(event)
226
+ return false unless event.is_a?(Hash)
227
+ return false if event['is_cancelled'] || event['is_all_day'] || event['response_status'] == 'declined'
228
+
229
+ event['id'].to_s.start_with?('AAMk') &&
230
+ event['online_meeting_url'].to_s.start_with?('https://teams.microsoft.com/')
231
+ end
232
+
233
+ def teems_executable = ENV.fetch('TEEMS_EXECUTABLE', 'teems')
234
+
235
+ def redacted_error(text)
236
+ lines = text.to_s.scrub.lines.map(&:strip).reject { |line| line.empty? || line.include?('warning:') }
237
+ message = lines.find { |line| line.start_with?('Error:') } || lines.first || 'unknown error'
238
+ message.gsub(%r{https?://\S+}, '[URL]').gsub(/Bearer\s+\S+/i, 'Bearer [REDACTED]')[0, 220]
239
+ end
240
+ end
241
+
242
+ # One transcript per recording: a meeting restarted mid-session has several.
243
+ module TranscriptSyncRecordings
244
+ NO_TRANSCRIPT = Regexp.union(/No recording sharing link found/i,
245
+ /No transcripts found for this recording/i,
246
+ /No meeting activity found for/i)
247
+
248
+ private
249
+
250
+ def sync_event(date, event, manifest)
251
+ urls = recording_urls(date, event)
252
+ return unavailable?(date, event) if urls.empty?
253
+ return preview_event?(date, event, urls, manifest) if @options[:dry_run]
254
+
255
+ urls.map { |url| sync_recording?(date, event, url, manifest) }.all?
256
+ rescue StandardError => e
257
+ failure "#{date}: failed: #{event_name(event)} (#{e.class}: #{e.message})"
258
+ false
259
+ end
260
+
261
+ def recording_urls(date, event)
262
+ stdout, stderr, status = Open3.capture3(teems_executable, 'meeting', event.fetch('id'),
263
+ '--date', date.iso8601, '--json')
264
+ return recordings_from(stdout) if status.success?
265
+ return [] if NO_TRANSCRIPT.match?("#{stderr} #{stdout}")
266
+
267
+ raise "teems meeting failed (exit #{status.exitstatus}): #{redacted_error("#{stderr} #{stdout}")}"
268
+ end
269
+
270
+ def recordings_from(json)
271
+ recordings = JSON.parse(json).fetch('recordings', [])
272
+ recordings.select { |rec| rec['url'] }.sort_by { |rec| rec['time'].to_s }.map { |rec| rec['url'] }.uniq
273
+ end
274
+
275
+ def recording_key(date, event, url) = Digest::SHA256.hexdigest("#{date.iso8601}:#{event.fetch('id')}:#{url}")
276
+
277
+ def legacy_key(date, event) = Digest::SHA256.hexdigest("#{date.iso8601}:#{event.fetch('id')}")
278
+
279
+ def sync_recording?(date, event, url, manifest)
280
+ key = recording_key(date, event, url)
281
+ downloaded?(key, manifest) || download_recording?(date, event, url, key, manifest)
282
+ end
283
+
284
+ def preview_event?(date, event, urls, manifest)
285
+ pending = urls.count { |url| !downloaded?(recording_key(date, event, url), manifest) }
286
+ pending -= 1 if pending.positive? && downloaded?(legacy_key(date, event), manifest)
287
+ log "#{date}: candidate: #{event_name(event)} (#{urls.length} recording(s), #{pending} not yet saved)"
288
+ true
289
+ end
290
+
291
+ def download_recording?(date, event, url, key, manifest)
292
+ Dir.mktmpdir('teems-transcript-', @output_dir) do |temp_dir|
293
+ stdout, stderr, status = Open3.capture3(teems_executable, 'meeting', event.fetch('id'),
294
+ '--date', date.iso8601, '--transcript',
295
+ '--recording-url', url, '-o', temp_dir)
296
+ files = Dir.glob(File.join(temp_dir, '*.vtt'))
297
+ return download_failure?(date, event, status, "#{stderr} #{stdout}") unless valid_download?(status, files)
298
+
299
+ persist_download(date, event, key, files.first, manifest)
300
+ end
301
+ true
302
+ end
303
+
304
+ def download_failure?(date, event, status, message)
305
+ return unavailable?(date, event) if NO_TRANSCRIPT.match?(message)
306
+
307
+ failure "#{date}: failed: #{event_name(event)} (exit #{status.exitstatus}; #{redacted_error(message)})"
308
+ false
309
+ end
310
+
311
+ def unavailable?(date, event)
312
+ log "#{date}: unavailable via teems: #{event_name(event)}"
313
+ true
314
+ end
315
+ end
316
+
317
+ # Optional user command run after a sync changes Markdown, e.g. to refresh a qmd index.
318
+ # A failing or slow command is reported but never fails the sync itself.
319
+ module TranscriptSyncHook
320
+ DEFAULT_HOOK_TIMEOUT = 300
321
+
322
+ private
323
+
324
+ def run_post_sync_hook
325
+ command = @hook['post_sync_command'].to_s.strip
326
+ return if command.empty? || @options[:no_post_sync] || @changed_markdown.empty?
327
+
328
+ log "Post-sync: running command for #{@changed_markdown.length} changed transcript(s)"
329
+ report_hook(*execute_hook(command))
330
+ rescue StandardError => e
331
+ @output.warn("Post-sync command could not run (#{e.class}: #{e.message})")
332
+ end
333
+
334
+ # Runs in its own process group so a timeout can stop the whole pipeline.
335
+ def execute_hook(command)
336
+ Open3.popen2e(hook_env, 'sh', '-c', command, pgroup: true) do |stdin, stdout, wait|
337
+ stdin.close
338
+ reader = Thread.new { stdout.read }
339
+ finished = wait.join(hook_timeout)
340
+ stop_hook(wait.pid) unless finished
341
+ [finished ? wait.value : nil, reader.value]
342
+ end
343
+ end
344
+
345
+ def stop_hook(pid)
346
+ Process.kill('KILL', -pid)
347
+ rescue Errno::ESRCH
348
+ nil
349
+ end
350
+
351
+ def hook_env
352
+ { 'TEEMS_TRANSCRIPTS_CHANGED' => @changed_markdown.join("\n"),
353
+ 'TEEMS_TRANSCRIPTS_CHANGED_COUNT' => @changed_markdown.length.to_s,
354
+ 'TEEMS_TRANSCRIPTS_MARKDOWN_DIR' => @markdown_dir,
355
+ 'TEEMS_TRANSCRIPTS_DIR' => @output_dir }
356
+ end
357
+
358
+ def hook_timeout = [@hook['post_sync_timeout']].grep(Numeric).find(&:positive?) || DEFAULT_HOOK_TIMEOUT
359
+
360
+ def report_hook(status, hook_output)
361
+ return log('Post-sync: done') if status&.success?
362
+
363
+ reason = status ? "exit #{status.exitstatus}" : "timed out after #{hook_timeout}s"
364
+ @output.warn("Post-sync command failed (#{reason}): #{hook_tail(hook_output)}")
365
+ end
366
+
367
+ def hook_tail(text)
368
+ lines = text.to_s.scrub.lines.map(&:strip).reject(&:empty?).last(3)
369
+ lines.empty? ? 'no output' : lines.join(' | ')[0, 300]
370
+ end
371
+ end
372
+
373
+ # Local manifest and replay engine. Never transfers transcripts to another machine.
374
+ class TranscriptSyncEngine
375
+ include TranscriptSyncFiles
376
+ include TranscriptSyncCalendar
377
+ include TranscriptSyncMarkdown
378
+ include TranscriptSyncRecordings
379
+ include TranscriptSyncHook
380
+
381
+ DEFAULT_LOOKBACK = 7
382
+ INITIAL_LOOKBACK = 30
383
+
384
+ def initialize(options, output, hook: {})
385
+ @options = options
386
+ @output = output
387
+ @hook = hook
388
+ @changed_markdown = []
389
+ data_home = ENV.fetch('XDG_DATA_HOME', File.join(Dir.home, '.local', 'share'))
390
+ state_home = ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local', 'state'))
391
+ @output_dir = File.join(data_home, 'teems', 'transcripts')
392
+ @markdown_dir = File.join(data_home, 'teems', 'transcripts-md')
393
+ @state_dir = File.join(state_home, 'teems')
394
+ @manifest_path = File.join(@state_dir, 'transcript-sync.json')
395
+ end
396
+
397
+ def run
398
+ File.umask(0o077)
399
+ return scan(load_manifest) if @options[:dry_run]
400
+
401
+ prepare_private_directories
402
+ File.open(File.join(@state_dir, 'transcript-sync.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
403
+ return log_locked unless lock.flock(File::LOCK_EX | File::LOCK_NB)
404
+
405
+ scan(load_manifest)
406
+ end
407
+ end
408
+
409
+ private
410
+
411
+ def log(message)
412
+ @output.puts(message) unless @options[:quiet]
413
+ end
414
+
415
+ def failure(message)
416
+ @output.error(message)
417
+ end
418
+
419
+ def dates_to_scan(manifest)
420
+ return [@options[:date]] if @options[:date]
421
+
422
+ days = lookback_days(manifest)
423
+ log "Scanning #{days} day(s) through #{Date.today} on this machine"
424
+ ((Date.today - days + 1)..Date.today).to_a
425
+ end
426
+
427
+ def lookback_days(manifest)
428
+ days = @options.fetch(:since, DEFAULT_LOOKBACK)
429
+ return days unless days == DEFAULT_LOOKBACK
430
+
431
+ previous = manifest['last_scan_date']
432
+ return INITIAL_LOOKBACK unless previous
433
+
434
+ # Catch up after time away, capped at the requested initial window.
435
+ (Date.today - Date.iso8601(previous) + 1).to_i.clamp(days, INITIAL_LOOKBACK)
436
+ end
437
+
438
+ def scan(manifest)
439
+ results = dates_to_scan(manifest).map { |date| scan_date(date, manifest) }
440
+ unless @options[:dry_run]
441
+ persist_scan(manifest, results.include?(nil))
442
+ results << !sync_markdown(manifest).include?(:failed)
443
+ run_post_sync_hook
444
+ end
445
+ results.all? ? 0 : 1
446
+ end
447
+
448
+ def scan_date(date, manifest)
449
+ results = calendar_events(date).map { |event| sync_event(date, event, manifest) }
450
+ results.all?
451
+ rescue StandardError => e
452
+ failure "#{date}: calendar error (#{e.class}: #{e.message})"
453
+ nil
454
+ end
455
+
456
+ def persist_scan(manifest, calendar_failed)
457
+ # Failed artifacts stay in the log and get seven-day retries. A calendar
458
+ # failure leaves the wider scan pending so historical days aren't lost.
459
+ manifest['last_scan_date'] = Date.today.iso8601 unless @options[:date] || calendar_failed
460
+ save_manifest(manifest)
461
+ end
462
+
463
+ def event_name(event) = event['subject'].to_s.gsub(/[\r\n]/, ' ')[0, 100]
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Teems
6
+ module Formatters
7
+ # Converts Teams WebVTT transcripts into speaker-turn Markdown for local search indexes.
8
+ # Consecutive cues from the same speaker are merged so each paragraph carries context.
9
+ class TranscriptMarkdown
10
+ Cue = Data.define(:start, :speaker, :text)
11
+
12
+ TIMING = /\A(?<start>(?:\d+:)?\d{2}:\d{2}\.\d{3})\s+-->/
13
+ VOICE = /<v(?:\.[^\s>]+)?\s+([^>]+)>/
14
+ ENTITIES = {
15
+ '&amp;' => '&', '&lt;' => '<', '&gt;' => '>', '&quot;' => '"', '&#39;' => "'",
16
+ '&apos;' => "'", '&nbsp;' => ' ', '&lrm;' => '', '&rlm;' => ''
17
+ }.freeze
18
+ ENTITY_PATTERN = Regexp.union(ENTITIES.keys)
19
+ MAX_TURN_CHARS = 1500
20
+
21
+ def initialize(vtt, title:, date: nil, source: nil)
22
+ @vtt = vtt.to_s
23
+ @title = single_line(title).then { |value| value.empty? ? 'Teams transcript' : value }
24
+ @date = date
25
+ @source = source
26
+ end
27
+
28
+ def render
29
+ lines = front_matter + ["# #{@title}", '', ['Teams transcript', @date].compact.join(' · '), '']
30
+ turns.each { |turn| lines.push(format_turn(turn), '') }
31
+ "#{lines.join("\n").rstrip}\n"
32
+ end
33
+
34
+ def turns
35
+ cues.each_with_object([]) { |cue, turns| append_cue(turns, cue) }
36
+ end
37
+
38
+ private
39
+
40
+ def append_cue(turns, cue)
41
+ last = turns.last
42
+ if last && last[:speaker] == cue.speaker && last[:text].length < MAX_TURN_CHARS
43
+ last[:text] << ' ' << cue.text
44
+ else
45
+ turns << { start: cue.start, speaker: cue.speaker, text: +cue.text }
46
+ end
47
+ end
48
+
49
+ def cues
50
+ normalized = @vtt.scrub.delete_prefix("\uFEFF").gsub(/\r\n?/, "\n")
51
+ normalized.split(/\n{2,}/).filter_map { |block| parse_cue(block) }
52
+ end
53
+
54
+ def parse_cue(block)
55
+ lines = block.lines.map(&:chomp)
56
+ timing_index = lines.index { |line| TIMING.match?(line) }
57
+ return unless timing_index
58
+
59
+ build_cue(lines[timing_index][TIMING, :start], lines[(timing_index + 1)..].join(' '))
60
+ end
61
+
62
+ def build_cue(start, raw)
63
+ text = clean(raw)
64
+ return if text.empty?
65
+
66
+ Cue.new(start: start, speaker: raw[VOICE, 1]&.then { |name| clean(name) }, text: text)
67
+ end
68
+
69
+ def clean(text) = single_line(decode(text.gsub(/<[^>]*>/, '')))
70
+
71
+ def decode(text) = text.gsub(ENTITY_PATTERN, ENTITIES)
72
+
73
+ def single_line(text) = text.to_s.gsub(/\s+/, ' ').strip
74
+
75
+ def front_matter
76
+ [
77
+ '---',
78
+ "title: #{@title.to_json}",
79
+ ("date: #{@date}" if @date),
80
+ 'source: teams-transcript',
81
+ ("vtt: #{@source.to_json}" if @source),
82
+ '---',
83
+ ''
84
+ ].compact
85
+ end
86
+
87
+ def format_turn(turn)
88
+ speaker = turn[:speaker] || 'Unknown speaker'
89
+ "**#{speaker}** (#{turn[:start].sub(/\.\d+\z/, '')}): #{turn[:text]}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
3
  require 'json'
5
4
  require 'net/http'
6
5
  require 'uri'
@@ -24,7 +23,7 @@ module Teems
24
23
 
25
24
  def compile_helper(source, binary)
26
25
  log('Compiling headless token helper...')
27
- _stdout, stderr, status = Open3.capture3(*swiftc_command(source, binary))
26
+ _stdout, stderr, status = Support::Subprocess.capture3(*swiftc_command(source, binary))
28
27
  log_helper_stderr(stderr)
29
28
  status.success? ? binary : log_and_nil('Failed to compile helper')
30
29
  rescue Errno::ENOENT
@@ -117,7 +116,7 @@ module Teems
117
116
  return nil unless binary
118
117
 
119
118
  log('Trying headless token extraction...')
120
- output, stderr, status = Open3.capture3(binary, *build_helper_args)
119
+ output, stderr, status = Support::Subprocess.capture3(binary, *build_helper_args)
121
120
  log_helper_stderr(stderr)
122
121
  handle_helper_result(output, status.exitstatus)
123
122
  rescue StandardError => e
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
-
5
3
  module Teems
6
4
  module Services
7
5
  # Runs JavaScript in Safari's current tab via AppleScript
@@ -74,7 +72,7 @@ module Teems
74
72
  end
75
73
 
76
74
  def run_applescript(script)
77
- out, _err, status = Open3.capture3('osascript', '-e', script)
75
+ out, _err, status = Support::Subprocess.capture3('osascript', '-e', script)
78
76
  return nil unless status.success?
79
77
 
80
78
  out.strip