teems 0.3.2 → 0.3.3

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.
@@ -39,6 +39,24 @@ module Teems
39
39
  A failing or timed-out command is reported but does not fail the sync.
40
40
  HELP
41
41
 
42
+ OPTION_HANDLERS = {
43
+ '--since' => ->(opts, args) { opts[:since] = Integer(args.shift, exception: false) },
44
+ '--date' => ->(opts, args) { opts.merge!(Transcripts.date_option(args.shift)) },
45
+ '--dry-run' => ->(opts, _args) { opts[:dry_run] = true },
46
+ '--no-post-sync' => ->(opts, _args) { opts[:no_post_sync] = true }
47
+ }.freeze
48
+
49
+ def self.date_option(value)
50
+ { date: Date.iso8601(value) }
51
+ rescue ArgumentError, TypeError
52
+ { invalid_date: true }
53
+ end
54
+
55
+ def initialize(args, runner:)
56
+ @options = {}
57
+ super
58
+ end
59
+
42
60
  def execute
43
61
  validation = validate_options
44
62
  return validation if validation
@@ -54,13 +72,10 @@ module Teems
54
72
  protected
55
73
 
56
74
  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
75
+ handler = OPTION_HANDLERS[arg]
76
+ return super unless handler
77
+
78
+ handler.call(@options, pending)
64
79
  end
65
80
 
66
81
  def transcript_settings
@@ -68,13 +83,53 @@ module Teems
68
83
  settings.is_a?(Hash) ? settings : {}
69
84
  end
70
85
 
71
- def parse_date_option(value)
72
- @options[:date] = Date.iso8601(value)
73
- rescue ArgumentError, TypeError
74
- @options[:invalid_date] = true
86
+ def help_text = HELP
87
+ end
88
+
89
+ # Where transcript sync keeps its files: XDG data for transcripts, XDG state for the manifest
90
+ TranscriptPaths = Data.define(:output_dir, :markdown_dir, :state_dir) do
91
+ def self.from_env
92
+ data_dir = Support::XdgPaths.new.data_dir
93
+ state_home = ENV.fetch('XDG_STATE_HOME') { File.join(Dir.home, '.local', 'state') }
94
+ new(output_dir: File.join(data_dir, 'transcripts'), markdown_dir: File.join(data_dir, 'transcripts-md'),
95
+ state_dir: File.join(state_home, 'teems'))
75
96
  end
76
97
 
77
- def help_text = HELP
98
+ def manifest = File.join(state_dir, 'transcript-sync.json')
99
+
100
+ def lock = File.join(state_dir, 'transcript-sync.lock')
101
+ end
102
+
103
+ # A scheduled Teams meeting on one calendar date (one occurrence of a recurring series)
104
+ TranscriptMeeting = Data.define(:date, :event) do
105
+ def id = event.fetch('id')
106
+
107
+ def name = event['subject'].to_s.gsub(/[\r\n]/, ' ')[0, 100]
108
+
109
+ def status_line(status) = "#{date}: #{status}: #{name}"
110
+
111
+ def command_args = ['meeting', id, '--date', date.iso8601]
112
+
113
+ # Manifest key for one recording's transcript
114
+ def recording_key(url) = Digest::SHA256.hexdigest("#{date.iso8601}:#{id}:#{url}")
115
+
116
+ # Manifest key used before transcripts were tracked per recording
117
+ def legacy_key = Digest::SHA256.hexdigest("#{date.iso8601}:#{id}")
118
+ end
119
+
120
+ # One recording of a meeting; each has its own transcript
121
+ TranscriptRecording = Data.define(:meeting, :url) do
122
+ def key = meeting.recording_key(url)
123
+
124
+ def legacy_key = meeting.legacy_key
125
+
126
+ def date = meeting.date
127
+
128
+ def transcript_args(dir) = [*meeting.command_args, '--transcript', '--recording-url', url, '-o', dir]
129
+
130
+ def manifest_entry(file_name)
131
+ { 'file' => file_name, 'downloaded_at' => Time.now.iso8601, 'date' => date.iso8601, 'subject' => meeting.name }
132
+ end
78
133
  end
79
134
 
80
135
  # Private on-disk storage, separate from meeting-capture.
@@ -82,38 +137,44 @@ module Teems
82
137
  private
83
138
 
84
139
  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
140
+ [@paths.output_dir, @paths.state_dir].each { |dir| private_directory(dir) }
89
141
  end
90
142
 
143
+ def private_directory(dir)
144
+ FileUtils.mkdir_p(dir, mode: 0o700)
145
+ File.chmod(0o700, dir)
146
+ end
147
+
148
+ # Transcripts live flat in the output dir; manifest entries are trusted only for their basename
149
+ def transcript_path(file) = File.join(@paths.output_dir, File.basename(file))
150
+
91
151
  def log_locked
92
152
  log 'Another transcript sync is running; skipping.'
93
153
  0
94
154
  end
95
155
 
96
156
  def load_manifest
97
- File.file?(@manifest_path) ? JSON.parse(File.read(@manifest_path)) : { 'events' => {} }
157
+ path = @paths.manifest
158
+ File.file?(path) ? JSON.parse(File.read(path)) : { 'events' => {} }
98
159
  end
99
160
 
100
161
  def downloaded?(key, manifest)
101
162
  prior = manifest.fetch('events', {})[key]
102
- prior && valid_vtt?(File.join(@output_dir, File.basename(prior.fetch('file'))))
163
+ prior && valid_vtt?(transcript_path(prior.fetch('file')))
103
164
  end
104
165
 
105
- def persist_download(date, event, key, file, manifest)
106
- adopted = adopt_legacy_download(date, event, file, manifest)
166
+ def persist_download(recording, file, manifest)
167
+ key = recording.key
168
+ adopted = adopt_legacy_download(recording, file, manifest)
107
169
  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) }
170
+ manifest['events'][key] = recording.manifest_entry(target_name)
110
171
  save_manifest(manifest)
111
- log "#{date}: #{adopted ? 'already had' : 'saved'} #{target_name}"
172
+ log "#{recording.date}: #{adopted ? 'already had' : 'saved'} #{target_name}"
112
173
  end
113
174
 
114
175
  def store_download(file, key)
115
176
  target_name = "#{File.basename(file, '.vtt')}--#{key[0, 10]}.vtt"
116
- target = File.join(@output_dir, target_name)
177
+ target = transcript_path(target_name)
117
178
  File.rename(file, target) unless valid_vtt?(target)
118
179
  File.chmod(0o600, target)
119
180
  target_name
@@ -121,15 +182,16 @@ module Teems
121
182
 
122
183
  # Earlier syncs kept one transcript per event. Reuse that file when it is
123
184
  # 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]
185
+ def adopt_legacy_download(recording, file, manifest)
186
+ events = manifest['events']
187
+ key = recording.legacy_key
188
+ legacy = events[key]
127
189
  return unless legacy
128
190
 
129
- existing = File.join(@output_dir, File.basename(legacy.fetch('file')))
191
+ existing = transcript_path(legacy.fetch('file'))
130
192
  return unless valid_vtt?(existing) && FileUtils.identical?(existing, file)
131
193
 
132
- manifest['events'].delete(key)
194
+ events.delete(key)
133
195
  File.basename(existing)
134
196
  end
135
197
 
@@ -137,12 +199,14 @@ module Teems
137
199
  File.file?(file) && File.size(file) > 10 && File.open(file, 'rb') { |io| io.read(6) == 'WEBVTT' }
138
200
  end
139
201
 
140
- def valid_download?(status, files)
141
- status.success? && files.length == 1 && valid_vtt?(files.first)
202
+ # The single valid WebVTT `teems meeting --transcript` wrote into dir, if any
203
+ def downloaded_vtt(dir)
204
+ vtt, *extra = Dir.glob(File.join(dir, '*.vtt'))
205
+ vtt if extra.empty? && valid_vtt?(vtt.to_s)
142
206
  end
143
207
 
144
208
  def save_manifest(manifest)
145
- write_private(@manifest_path, "#{JSON.pretty_generate(manifest)}\n")
209
+ write_private(@paths.manifest, "#{JSON.pretty_generate(manifest)}\n")
146
210
  end
147
211
 
148
212
  def write_private(path, content)
@@ -159,42 +223,43 @@ module Teems
159
223
  module TranscriptSyncMarkdown
160
224
  private
161
225
 
226
+ # Returns [Markdown paths written this run, whether every export succeeded]
162
227
  def sync_markdown(manifest)
163
- FileUtils.mkdir_p(@markdown_dir, mode: 0o700)
164
- File.chmod(0o700, @markdown_dir)
228
+ private_directory(@paths.markdown_dir)
165
229
  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
230
+ written = results.grep(String)
231
+ log "Markdown: updated #{written.length} transcript(s)" unless written.empty?
232
+ [written, !results.include?(:failed)]
169
233
  end
170
234
 
235
+ # Returns the Markdown path it wrote, or :missing, :current, or :failed
171
236
  def export_markdown(entry)
172
- vtt = File.join(@output_dir, File.basename(entry.fetch('file')))
237
+ vtt = transcript_path(entry.fetch('file'))
173
238
  return :missing unless valid_vtt?(vtt)
174
239
 
175
- target = File.join(@markdown_dir, "#{File.basename(vtt, '.vtt')}.md")
240
+ target = File.join(@paths.markdown_dir, "#{File.basename(vtt, '.vtt')}.md")
176
241
  return :current if markdown_current?(target, vtt)
177
242
 
178
243
  write_private(target, markdown_for(vtt, entry))
179
- @changed_markdown << target
180
- :written
244
+ target
181
245
  rescue StandardError => e
182
- markdown_failure(entry, e)
246
+ markdown_failure(entry, "#{e.class}: #{e.message}")
183
247
  end
184
248
 
185
249
  def markdown_current?(target, vtt) = File.file?(target) && File.mtime(target) >= File.mtime(vtt)
186
250
 
187
- def markdown_failure(entry, error)
188
- failure "Markdown export failed for #{File.basename(entry['file'].to_s)} (#{error.class}: #{error.message})"
251
+ def markdown_failure(entry, reason)
252
+ failure "Markdown export failed for #{File.basename(entry['file'].to_s)} (#{reason})"
189
253
  :failed
190
254
  end
191
255
 
192
256
  def markdown_for(vtt, entry)
257
+ subject, date = entry.values_at('subject', 'date')
193
258
  stem = File.basename(vtt, '.vtt').sub(/--\h{10}\z/, '')
194
259
  Formatters::TranscriptMarkdown.new(
195
260
  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),
261
+ title: subject || stem.sub(/\A\d{4}-\d{2}-\d{2} - /, '').sub(/-\d{8}_\d{6}UTC\z/, ''),
262
+ date: date || transcript_date(stem),
198
263
  source: File.basename(vtt)
199
264
  ).render
200
265
  end
@@ -247,124 +312,130 @@ module Teems
247
312
 
248
313
  private
249
314
 
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]
315
+ def sync_event(meeting, manifest)
316
+ urls = recording_urls(meeting)
317
+ return unavailable?(meeting) if urls.empty?
318
+ return preview_event?(meeting, urls, manifest) if @options[:dry_run]
254
319
 
255
- urls.map { |url| sync_recording?(date, event, url, manifest) }.all?
320
+ urls.map { |url| sync_recording?(TranscriptRecording.new(meeting: meeting, url: url), manifest) }.all?
256
321
  rescue StandardError => e
257
- failure "#{date}: failed: #{event_name(event)} (#{e.class}: #{e.message})"
322
+ failure "#{meeting.status_line('failed')} (#{e.class}: #{e.message})"
258
323
  false
259
324
  end
260
325
 
261
- def recording_urls(date, event)
262
- stdout, stderr, status = Open3.capture3(teems_executable, 'meeting', event.fetch('id'),
263
- '--date', date.iso8601, '--json')
326
+ def recording_urls(meeting)
327
+ stdout, stderr, status = Open3.capture3(teems_executable, *meeting.command_args, '--json')
264
328
  return recordings_from(stdout) if status.success?
265
- return [] if NO_TRANSCRIPT.match?("#{stderr} #{stdout}")
266
329
 
267
- raise "teems meeting failed (exit #{status.exitstatus}): #{redacted_error("#{stderr} #{stdout}")}"
330
+ message = "#{stderr} #{stdout}"
331
+ return [] if NO_TRANSCRIPT.match?(message)
332
+
333
+ raise "teems meeting failed (exit #{status.exitstatus}): #{redacted_error(message)}"
268
334
  end
269
335
 
336
+ # Recording URLs in start-time order, skipping recordings without a sharing link
270
337
  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
338
+ pairs = JSON.parse(json).fetch('recordings', []).map { |rec| rec.values_at('time', 'url') }
339
+ pairs.select(&:last).sort_by { |time, _url| time.to_s }.map(&:last).uniq
273
340
  end
274
341
 
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)
342
+ def sync_recording?(recording, manifest)
343
+ downloaded?(recording.key, manifest) || download_recording?(recording, manifest)
282
344
  end
283
345
 
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)"
346
+ def preview_event?(meeting, urls, manifest)
347
+ pending = urls.count { |url| !downloaded?(meeting.recording_key(url), manifest) }
348
+ pending -= 1 if pending.positive? && downloaded?(meeting.legacy_key, manifest)
349
+ log "#{meeting.status_line('candidate')} (#{urls.length} recording(s), #{pending} not yet saved)"
288
350
  true
289
351
  end
290
352
 
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)
353
+ def download_recording?(recording, manifest)
354
+ Dir.mktmpdir('teems-transcript-', @paths.output_dir) do |temp_dir|
355
+ stdout, stderr, status = Open3.capture3(teems_executable, *recording.transcript_args(temp_dir))
356
+ vtt = downloaded_vtt(temp_dir)
357
+ return download_failure?(recording.meeting, status, "#{stderr} #{stdout}") unless status.success? && vtt
298
358
 
299
- persist_download(date, event, key, files.first, manifest)
359
+ persist_download(recording, vtt, manifest)
300
360
  end
301
361
  true
302
362
  end
303
363
 
304
- def download_failure?(date, event, status, message)
305
- return unavailable?(date, event) if NO_TRANSCRIPT.match?(message)
364
+ def download_failure?(meeting, status, message)
365
+ return unavailable?(meeting) if NO_TRANSCRIPT.match?(message)
306
366
 
307
- failure "#{date}: failed: #{event_name(event)} (exit #{status.exitstatus}; #{redacted_error(message)})"
367
+ failure "#{meeting.status_line('failed')} (exit #{status.exitstatus}; #{redacted_error(message)})"
308
368
  false
309
369
  end
310
370
 
311
- def unavailable?(date, event)
312
- log "#{date}: unavailable via teems: #{event_name(event)}"
371
+ def unavailable?(meeting)
372
+ log meeting.status_line('unavailable via teems')
313
373
  true
314
374
  end
315
375
  end
316
376
 
317
377
  # Optional user command run after a sync changes Markdown, e.g. to refresh a qmd index.
318
378
  # A failing or slow command is reported but never fails the sync itself.
319
- module TranscriptSyncHook
320
- DEFAULT_HOOK_TIMEOUT = 300
379
+ class TranscriptPostSyncHook
380
+ DEFAULT_TIMEOUT = 300
321
381
 
322
- private
382
+ def initialize(settings, paths:, output:, quiet:)
383
+ @settings = settings
384
+ @paths = paths
385
+ @output = output
386
+ @quiet = quiet
387
+ end
323
388
 
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?
389
+ def run(changed)
390
+ command = @settings['post_sync_command'].to_s.strip
391
+ return if command.empty? || changed.empty?
327
392
 
328
- log "Post-sync: running command for #{@changed_markdown.length} changed transcript(s)"
329
- report_hook(*execute_hook(command))
393
+ log "Post-sync: running command for #{changed.length} changed transcript(s)"
394
+ report(*execute(command, changed))
330
395
  rescue StandardError => e
331
396
  @output.warn("Post-sync command could not run (#{e.class}: #{e.message})")
332
397
  end
333
398
 
399
+ private
400
+
401
+ def log(message)
402
+ @output.puts(message) unless @quiet
403
+ end
404
+
334
405
  # 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|
406
+ def execute(command, changed)
407
+ Open3.popen2e(env(changed), 'sh', '-c', command, pgroup: true) do |stdin, stdout, wait|
337
408
  stdin.close
338
409
  reader = Thread.new { stdout.read }
339
- finished = wait.join(hook_timeout)
340
- stop_hook(wait.pid) unless finished
410
+ finished = wait.join(timeout)
411
+ stop(wait.pid) unless finished
341
412
  [finished ? wait.value : nil, reader.value]
342
413
  end
343
414
  end
344
415
 
345
- def stop_hook(pid)
416
+ def stop(pid)
346
417
  Process.kill('KILL', -pid)
347
418
  rescue Errno::ESRCH
348
419
  nil
349
420
  end
350
421
 
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 }
422
+ def env(changed)
423
+ { 'TEEMS_TRANSCRIPTS_CHANGED' => changed.join("\n"),
424
+ 'TEEMS_TRANSCRIPTS_CHANGED_COUNT' => changed.length.to_s,
425
+ 'TEEMS_TRANSCRIPTS_MARKDOWN_DIR' => @paths.markdown_dir,
426
+ 'TEEMS_TRANSCRIPTS_DIR' => @paths.output_dir }
356
427
  end
357
428
 
358
- def hook_timeout = [@hook['post_sync_timeout']].grep(Numeric).find(&:positive?) || DEFAULT_HOOK_TIMEOUT
429
+ def timeout = [@settings['post_sync_timeout']].grep(Numeric).find(&:positive?) || DEFAULT_TIMEOUT
359
430
 
360
- def report_hook(status, hook_output)
431
+ def report(status, hook_output)
361
432
  return log('Post-sync: done') if status&.success?
362
433
 
363
- reason = status ? "exit #{status.exitstatus}" : "timed out after #{hook_timeout}s"
364
- @output.warn("Post-sync command failed (#{reason}): #{hook_tail(hook_output)}")
434
+ reason = status ? "exit #{status.exitstatus}" : "timed out after #{timeout}s"
435
+ @output.warn("Post-sync command failed (#{reason}): #{tail(hook_output)}")
365
436
  end
366
437
 
367
- def hook_tail(text)
438
+ def tail(text)
368
439
  lines = text.to_s.scrub.lines.map(&:strip).reject(&:empty?).last(3)
369
440
  lines.empty? ? 'no output' : lines.join(' | ')[0, 300]
370
441
  end
@@ -376,7 +447,6 @@ module Teems
376
447
  include TranscriptSyncCalendar
377
448
  include TranscriptSyncMarkdown
378
449
  include TranscriptSyncRecordings
379
- include TranscriptSyncHook
380
450
 
381
451
  DEFAULT_LOOKBACK = 7
382
452
  INITIAL_LOOKBACK = 30
@@ -384,30 +454,26 @@ module Teems
384
454
  def initialize(options, output, hook: {})
385
455
  @options = options
386
456
  @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')
457
+ @paths = TranscriptPaths.from_env
458
+ @hook = TranscriptPostSyncHook.new(hook, paths: @paths, output: output, quiet: options[:quiet])
395
459
  end
396
460
 
397
461
  def run
398
462
  File.umask(0o077)
399
- return scan(load_manifest) if @options[:dry_run]
463
+ @options[:dry_run] ? scan(load_manifest) : locked_scan
464
+ end
465
+
466
+ private
400
467
 
468
+ def locked_scan
401
469
  prepare_private_directories
402
- File.open(File.join(@state_dir, 'transcript-sync.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
470
+ File.open(@paths.lock, File::RDWR | File::CREAT, 0o600) do |lock|
403
471
  return log_locked unless lock.flock(File::LOCK_EX | File::LOCK_NB)
404
472
 
405
473
  scan(load_manifest)
406
474
  end
407
475
  end
408
476
 
409
- private
410
-
411
477
  def log(message)
412
478
  @output.puts(message) unless @options[:quiet]
413
479
  end
@@ -417,14 +483,16 @@ module Teems
417
483
  end
418
484
 
419
485
  def dates_to_scan(manifest)
420
- return [@options[:date]] if @options[:date]
486
+ date = @options[:date]
487
+ return [date] if date
421
488
 
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
489
+ today = Date.today
490
+ days = lookback_days(manifest, today)
491
+ log "Scanning #{days} day(s) through #{today} on this machine"
492
+ ((today - days + 1)..today).to_a
425
493
  end
426
494
 
427
- def lookback_days(manifest)
495
+ def lookback_days(manifest, today)
428
496
  days = @options.fetch(:since, DEFAULT_LOOKBACK)
429
497
  return days unless days == DEFAULT_LOOKBACK
430
498
 
@@ -432,35 +500,35 @@ module Teems
432
500
  return INITIAL_LOOKBACK unless previous
433
501
 
434
502
  # Catch up after time away, capped at the requested initial window.
435
- (Date.today - Date.iso8601(previous) + 1).to_i.clamp(days, INITIAL_LOOKBACK)
503
+ (today - Date.iso8601(previous) + 1).to_i.clamp(days, INITIAL_LOOKBACK)
436
504
  end
437
505
 
438
506
  def scan(manifest)
439
507
  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
508
+ results << finish_scan(manifest, results) unless @options[:dry_run]
445
509
  results.all? ? 0 : 1
446
510
  end
447
511
 
448
512
  def scan_date(date, manifest)
449
- results = calendar_events(date).map { |event| sync_event(date, event, manifest) }
513
+ results = calendar_events(date).map do |event|
514
+ sync_event(TranscriptMeeting.new(date: date, event: event), manifest)
515
+ end
450
516
  results.all?
451
517
  rescue StandardError => e
452
518
  failure "#{date}: calendar error (#{e.class}: #{e.message})"
453
519
  nil
454
520
  end
455
521
 
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
522
+ # Records the scan, refreshes Markdown, and runs the post-sync hook; returns whether Markdown export succeeded.
523
+ # Failed artifacts stay in the log and get seven-day retries. A calendar failure (nil result)
524
+ # leaves the wider scan pending so historical days aren't lost.
525
+ def finish_scan(manifest, results)
526
+ manifest['last_scan_date'] = Date.today.iso8601 unless @options[:date] || results.include?(nil)
460
527
  save_manifest(manifest)
528
+ changed, markdown_ok = sync_markdown(manifest)
529
+ @hook.run(changed) unless @options[:no_post_sync]
530
+ markdown_ok
461
531
  end
462
-
463
- def event_name(event) = event['subject'].to_s.gsub(/[\r\n]/, ' ')[0, 100]
464
532
  end
465
533
  end
466
534
  end
@@ -26,6 +26,9 @@ module Teems
26
26
  att.is_a?(Hash) ? (att['fileName'] || att['name'] || 'file') : att.to_s
27
27
  end
28
28
 
29
+ # One-line description of a message's inline images, e.g. "image (881x179), chart"
30
+ def image_summary(images) = images.map(&:label).join(', ')
31
+
29
32
  def format_single_reaction(reaction, emoji_map)
30
33
  type = reaction[:type]
31
34
  emoji = emoji_map[type] || type
@@ -15,10 +15,12 @@ module Teems
15
15
  '1f440_eyes' => "\u{1F440}", 'thumbsdown' => "\u{1F44E}"
16
16
  }.freeze
17
17
 
18
- def initialize(chat_name:, chat_type: nil, synced_at: nil)
18
+ # image_link: optional callable returning a relative path for a locally saved InlineImage
19
+ def initialize(chat_name:, chat_type: nil, synced_at: nil, image_link: nil)
19
20
  @chat_name = chat_name
20
21
  @chat_type = chat_type
21
22
  @synced_at = synced_at
23
+ @image_link = image_link
22
24
  end
23
25
 
24
26
  # Format an array of Message objects into a Markdown string.
@@ -68,6 +70,7 @@ module Teems
68
70
  content = msg.content
69
71
  result = content.to_s.empty? ? [] : [content]
70
72
  result.concat(format_message_attachments(msg))
73
+ result.concat(msg.images.map { |image| format_image(image) })
71
74
  result.concat(format_message_reactions(msg))
72
75
  end
73
76
 
@@ -93,6 +96,12 @@ module Teems
93
96
  url&.start_with?('https://') ? "\u{1F4CE} [#{name}](#{url})" : "\u{1F4CE} #{name}"
94
97
  end
95
98
 
99
+ def format_image(image)
100
+ label = "image: #{image.label}"
101
+ path = @image_link&.call(image)
102
+ path ? "![#{label}](#{path})" : "[#{label}]"
103
+ end
104
+
96
105
  def format_message_reactions(msg)
97
106
  reactions = msg.reactions
98
107
  return [] unless displayable_reactions?(reactions)
@@ -21,8 +21,8 @@ module Teems
21
21
 
22
22
  def format(message)
23
23
  content = highlight_mentions(message.content, message.mentions)
24
- [format_header(message), " #{content}", format_attachments(message), format_reactions(message)]
25
- .compact.join("\n")
24
+ [format_header(message), " #{content}", format_attachments(message), format_images(message),
25
+ format_reactions(message)].compact.join("\n")
26
26
  end
27
27
 
28
28
  private
@@ -44,6 +44,11 @@ module Teems
44
44
  " #{@output.gray("\u{1F4CE} #{attachment_names(attachments)}")}"
45
45
  end
46
46
 
47
+ def format_images(message)
48
+ summary = FormatUtils.image_summary(message.images)
49
+ " #{@output.gray("\u{1F5BC}\u{FE0F} #{summary}")}" unless summary.empty?
50
+ end
51
+
47
52
  def format_reactions(message)
48
53
  reactions = message.reactions
49
54
  return unless reactions.any?