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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +7 -0
- data/lib/teems/commands/meeting.rb +4 -3
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +30 -3
- data/lib/teems/commands/transcripts.rb +205 -137
- data/lib/teems/formatters/format_utils.rb +3 -0
- data/lib/teems/formatters/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/transcript_markdown.rb +16 -12
- data/lib/teems/models/inline_image.rb +83 -0
- data/lib/teems/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +5 -0
- data/lib/teems/services/inline_image_downloader.rb +115 -0
- data/lib/teems/services/sync_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -0
- data/lib/teems/support/subprocess.rb +2 -0
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +4 -0
- metadata +4 -1
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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?(
|
|
163
|
+
prior && valid_vtt?(transcript_path(prior.fetch('file')))
|
|
103
164
|
end
|
|
104
165
|
|
|
105
|
-
def persist_download(
|
|
106
|
-
|
|
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] =
|
|
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 =
|
|
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(
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
191
|
+
existing = transcript_path(legacy.fetch('file'))
|
|
130
192
|
return unless valid_vtt?(existing) && FileUtils.identical?(existing, file)
|
|
131
193
|
|
|
132
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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(@
|
|
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
|
-
|
|
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.
|
|
167
|
-
log "Markdown: updated #{written} transcript(s)"
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
188
|
-
failure "Markdown export failed for #{File.basename(entry['file'].to_s)} (#{
|
|
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:
|
|
197
|
-
date:
|
|
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(
|
|
251
|
-
urls = recording_urls(
|
|
252
|
-
return unavailable?(
|
|
253
|
-
return preview_event?(
|
|
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?(
|
|
320
|
+
urls.map { |url| sync_recording?(TranscriptRecording.new(meeting: meeting, url: url), manifest) }.all?
|
|
256
321
|
rescue StandardError => e
|
|
257
|
-
failure "#{
|
|
322
|
+
failure "#{meeting.status_line('failed')} (#{e.class}: #{e.message})"
|
|
258
323
|
false
|
|
259
324
|
end
|
|
260
325
|
|
|
261
|
-
def recording_urls(
|
|
262
|
-
stdout, stderr, status = Open3.capture3(teems_executable,
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
|
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?(
|
|
285
|
-
pending = urls.count { |url| !downloaded?(recording_key(
|
|
286
|
-
pending -= 1 if pending.positive? && downloaded?(legacy_key
|
|
287
|
-
log "#{
|
|
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?(
|
|
292
|
-
Dir.mktmpdir('teems-transcript-', @output_dir) do |temp_dir|
|
|
293
|
-
stdout, stderr, status = Open3.capture3(teems_executable,
|
|
294
|
-
|
|
295
|
-
|
|
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(
|
|
359
|
+
persist_download(recording, vtt, manifest)
|
|
300
360
|
end
|
|
301
361
|
true
|
|
302
362
|
end
|
|
303
363
|
|
|
304
|
-
def download_failure?(
|
|
305
|
-
return unavailable?(
|
|
364
|
+
def download_failure?(meeting, status, message)
|
|
365
|
+
return unavailable?(meeting) if NO_TRANSCRIPT.match?(message)
|
|
306
366
|
|
|
307
|
-
failure "#{
|
|
367
|
+
failure "#{meeting.status_line('failed')} (exit #{status.exitstatus}; #{redacted_error(message)})"
|
|
308
368
|
false
|
|
309
369
|
end
|
|
310
370
|
|
|
311
|
-
def unavailable?(
|
|
312
|
-
log
|
|
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
|
-
|
|
320
|
-
|
|
379
|
+
class TranscriptPostSyncHook
|
|
380
|
+
DEFAULT_TIMEOUT = 300
|
|
321
381
|
|
|
322
|
-
|
|
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
|
|
325
|
-
command = @
|
|
326
|
-
return if command.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 #{
|
|
329
|
-
|
|
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
|
|
336
|
-
Open3.popen2e(
|
|
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(
|
|
340
|
-
|
|
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
|
|
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
|
|
352
|
-
{ 'TEEMS_TRANSCRIPTS_CHANGED' =>
|
|
353
|
-
'TEEMS_TRANSCRIPTS_CHANGED_COUNT' =>
|
|
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
|
|
429
|
+
def timeout = [@settings['post_sync_timeout']].grep(Numeric).find(&:positive?) || DEFAULT_TIMEOUT
|
|
359
430
|
|
|
360
|
-
def
|
|
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 #{
|
|
364
|
-
@output.warn("Post-sync command failed (#{reason}): #{
|
|
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
|
|
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
|
-
@
|
|
388
|
-
@
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
486
|
+
date = @options[:date]
|
|
487
|
+
return [date] if date
|
|
421
488
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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}]"
|
|
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),
|
|
25
|
-
|
|
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?
|