teems 0.2.0 → 0.3.1

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.
@@ -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
@@ -24,6 +24,7 @@ module Teems
24
24
  teems messages 19:abc123@thread.v2 # Read chat messages
25
25
  teems messages 19:abc123@thread.v2 -n 50 # Show 50 messages
26
26
  teems messages "https://teams.microsoft.com/l/message/19:abc@thread.v2/123?context=..."
27
+ # Show the linked message and its thread replies
27
28
  HELP
28
29
 
29
30
  # Display formatting for messages command
@@ -167,10 +168,51 @@ module Teems
167
168
  end
168
169
  end
169
170
 
171
+ # Fetch and render a single message and its thread replies
172
+ module ThreadFetch
173
+ private
174
+
175
+ def fetch_thread(thread_id, message_id)
176
+ api = runner.messages_api
177
+ parent_data = with_token_refresh { api.message(thread_id: thread_id, message_id: message_id) }
178
+ replies_data = with_token_refresh do
179
+ api.replies(thread_id: thread_id, message_id: message_id, limit: @options[:limit])
180
+ end
181
+ display_thread(parent_data, extract_messages_data(replies_data))
182
+ rescue ApiError => e
183
+ error("Failed to fetch message: #{e.message}")
184
+ end
185
+
186
+ def display_thread(parent_data, replies_data)
187
+ parent = Models::Message.from_api(parent_data)
188
+ replies = replies_data.map { |data| Models::Message.from_api(data) }.reject(&:system_message?).reverse
189
+ return output_json(thread_to_hash(parent, replies)) if @options[:json]
190
+
191
+ render_thread(parent, replies)
192
+ download_attachments([parent, *replies]) if @options[:download]
193
+ 0
194
+ end
195
+
196
+ def render_thread(parent, replies)
197
+ display_message(parent)
198
+ count = replies.length
199
+ return if count.zero?
200
+
201
+ puts output.gray("--- #{count} #{count == 1 ? 'reply' : 'replies'} ---")
202
+ puts
203
+ replies.each { |reply| display_message(reply) }
204
+ end
205
+
206
+ def thread_to_hash(parent, replies)
207
+ { parent: message_to_hash(parent), replies: replies.map { |reply| message_to_hash(reply) } }
208
+ end
209
+ end
210
+
170
211
  # Read messages from a channel or chat
171
212
  class Messages < Base
172
213
  include MessagesDisplay
173
214
  include AttachmentDownload
215
+ include ThreadFetch
174
216
 
175
217
  def initialize(args, runner:)
176
218
  @options = {}
@@ -231,12 +273,17 @@ module Teems
231
273
  def apply_parsed_url(result)
232
274
  conversation_id = result.conversation_id
233
275
  team_id = result.team_id
234
- debug("Parsed URL: conversation=#{conversation_id}, team=#{team_id}")
276
+ message_id = result.message_id
277
+ debug("Parsed URL: conversation=#{conversation_id}, team=#{team_id}, message=#{message_id}")
235
278
  @options[:team_id] = team_id if team_id
279
+ @options[:message_id] = message_id if message_id
236
280
  conversation_id
237
281
  end
238
282
 
239
283
  def fetch_messages(target)
284
+ message_id = @options[:message_id]
285
+ return fetch_thread(target, message_id) if message_id
286
+
240
287
  @options[:team_id] ? fetch_channel_messages(target) : fetch_chat_messages(target)
241
288
  end
242
289
 
@@ -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
 
@@ -7,16 +7,18 @@ module Teems
7
7
  private
8
8
 
9
9
  def walk_manager_chain(target_id)
10
- managers = []
11
- current_id = target_id
12
10
  fetch = method(target_is_me? ? :fetch_my_manager : :fetch_user_manager)
13
- collect_managers(managers, current_id, fetch)
11
+ collect_managers(target_id, fetch, Set[target_id])
14
12
  end
15
13
 
16
- def collect_managers(managers, current_id, fetch)
14
+ def collect_managers(current_id, fetch, visited)
15
+ managers = []
17
16
  while (mgr = fetch.call(current_id))
17
+ mgr_id = mgr.id
18
+ break unless visited.add?(mgr_id)
19
+
18
20
  managers.unshift(mgr)
19
- current_id = mgr.id
21
+ current_id = mgr_id
20
22
  fetch = method(:fetch_user_manager)
21
23
  end
22
24
  managers
@@ -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