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
|
@@ -7,8 +7,18 @@ module Teems
|
|
|
7
7
|
# Converts Teams WebVTT transcripts into speaker-turn Markdown for local search indexes.
|
|
8
8
|
# Consecutive cues from the same speaker are merged so each paragraph carries context.
|
|
9
9
|
class TranscriptMarkdown
|
|
10
|
+
# One timed WebVTT cue: its start timestamp, speaker (nil when unattributed), and plain text
|
|
10
11
|
Cue = Data.define(:start, :speaker, :text)
|
|
11
12
|
|
|
13
|
+
# Consecutive cues from one speaker, merged into a paragraph until it is too long to index well
|
|
14
|
+
Turn = Struct.new(:start, :speaker, :text) do
|
|
15
|
+
def self.from(cue) = new(cue.start, cue.speaker, +cue.text)
|
|
16
|
+
|
|
17
|
+
def accepts?(cue) = cue.speaker == speaker && text.length < MAX_TURN_CHARS
|
|
18
|
+
|
|
19
|
+
def add(cue) = text << ' ' << cue.text
|
|
20
|
+
end
|
|
21
|
+
|
|
12
22
|
TIMING = /\A(?<start>(?:\d+:)?\d{2}:\d{2}\.\d{3})\s+-->/
|
|
13
23
|
VOICE = /<v(?:\.[^\s>]+)?\s+([^>]+)>/
|
|
14
24
|
ENTITIES = {
|
|
@@ -39,11 +49,7 @@ module Teems
|
|
|
39
49
|
|
|
40
50
|
def append_cue(turns, cue)
|
|
41
51
|
last = turns.last
|
|
42
|
-
|
|
43
|
-
last[:text] << ' ' << cue.text
|
|
44
|
-
else
|
|
45
|
-
turns << { start: cue.start, speaker: cue.speaker, text: +cue.text }
|
|
46
|
-
end
|
|
52
|
+
last&.accepts?(cue) ? last.add(cue) : turns << Turn.from(cue)
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def cues
|
|
@@ -51,12 +57,10 @@ module Teems
|
|
|
51
57
|
normalized.split(/\n{2,}/).filter_map { |block| parse_cue(block) }
|
|
52
58
|
end
|
|
53
59
|
|
|
60
|
+
# Cue text is every line after the timing line; identifier lines before it are skipped
|
|
54
61
|
def parse_cue(block)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return unless timing_index
|
|
58
|
-
|
|
59
|
-
build_cue(lines[timing_index][TIMING, :start], lines[(timing_index + 1)..].join(' '))
|
|
62
|
+
timing, *text = block.lines(chomp: true).drop_while { |line| !TIMING.match?(line) }
|
|
63
|
+
build_cue(timing[TIMING, :start], text.join(' ')) if timing
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
def build_cue(start, raw)
|
|
@@ -85,8 +89,8 @@ module Teems
|
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
def format_turn(turn)
|
|
88
|
-
speaker = turn
|
|
89
|
-
"**#{speaker}** (#{turn
|
|
92
|
+
speaker = turn.speaker || 'Unknown speaker'
|
|
93
|
+
"**#{speaker}** (#{turn.start.sub(/\.\d+\z/, '')}): #{turn.text}"
|
|
90
94
|
end
|
|
91
95
|
end
|
|
92
96
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Teems
|
|
6
|
+
module Models
|
|
7
|
+
# Extracts inline images (screenshots pasted into a message) from Teams message HTML.
|
|
8
|
+
# Teams stores them in its AMS object store and references them with
|
|
9
|
+
# <img itemtype="http://schema.skype.com/AMSImage" src="https://<ams-host>/v1/objects/<id>/views/imgo">.
|
|
10
|
+
module InlineImageParsing
|
|
11
|
+
IMG_TAG = /<img\b[^>]*>/i
|
|
12
|
+
ATTRIBUTE = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
13
|
+
AMS_ITEMTYPE = 'schema.skype.com/AMSImage'
|
|
14
|
+
AMS_PATH = %r{\Ahttps://[^/\s]+/v1/objects/([^/?#\s]+)/views/}
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def parse(html)
|
|
19
|
+
return [] unless html.is_a?(String) && html.match?(/<img/i)
|
|
20
|
+
|
|
21
|
+
html.scan(IMG_TAG).filter_map { |tag| image_from(tag_attributes(tag)) }.uniq(&:url)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tag_attributes(tag)
|
|
25
|
+
tag.scan(ATTRIBUTE).to_h { |name, double, single| [name.downcase, CGI.unescapeHTML(double || single)] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def image_from(attrs)
|
|
29
|
+
url = attrs['src'].to_s
|
|
30
|
+
return unless ams_image?(attrs, url)
|
|
31
|
+
|
|
32
|
+
InlineImage.new(id: attrs['itemid'] || url[AMS_PATH, 1], url: url, alt: attrs['alt'],
|
|
33
|
+
width: dimension(attrs['width']), height: dimension(attrs['height']),
|
|
34
|
+
format: attrs['itemscope'])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ams_image?(attrs, url)
|
|
38
|
+
url.start_with?('https://') && (attrs['itemtype'].to_s.include?(AMS_ITEMTYPE) || url.match?(AMS_PATH))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dimension(value) = Integer(value.to_s, exception: false)&.then { |num| num.positive? ? num : nil }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# An image pasted inline into a Teams message body
|
|
45
|
+
InlineImage = Data.define(:id, :url, :alt, :width, :height, :format) do
|
|
46
|
+
def self.parse_html(html) = InlineImageParsing.parse(html)
|
|
47
|
+
|
|
48
|
+
# Rebuilds an image from its stored (JSON) hash form
|
|
49
|
+
def self.from_h(hash)
|
|
50
|
+
return unless hash.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
attrs = hash.transform_keys(&:to_sym).slice(*members)
|
|
53
|
+
attrs[:url] ? new(**attrs) : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Only the URL is required; the other attributes default to nil
|
|
57
|
+
def initialize(url:, **optional)
|
|
58
|
+
super(url: url, id: nil, alt: nil, width: nil, height: nil, format: nil, **optional)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The original upload: `src` usually points at a recompressed JPEG preview,
|
|
62
|
+
# while this view is what Teams opens for "view full image".
|
|
63
|
+
def full_size_url
|
|
64
|
+
url.sub(%r{(/v1/objects/[^/]+/views/)[^/?#]+}, '\1imgpsh_fullsize_anim')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# JSON form: the stored attributes plus the original-resolution URL
|
|
68
|
+
def as_json = to_h.merge(full_size_url: full_size_url)
|
|
69
|
+
|
|
70
|
+
def dimensions = width && height ? "#{width}x#{height}" : nil
|
|
71
|
+
|
|
72
|
+
def label
|
|
73
|
+
name = alt.to_s.strip
|
|
74
|
+
name = 'image' if name.empty?
|
|
75
|
+
size = dimensions
|
|
76
|
+
size ? "#{name} (#{size})" : name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Filesystem-safe name derived from the AMS object id
|
|
80
|
+
def file_stem = (id || url[%r{/v1/objects/([^/]+)/}, 1] || 'image').to_s.gsub(/[^\w.-]/, '_')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/teems/models/message.rb
CHANGED
|
@@ -7,10 +7,13 @@ module Teems
|
|
|
7
7
|
Message = Data.define(
|
|
8
8
|
:id, :sender_id, :sender_name, :content, :created_at,
|
|
9
9
|
:message_type, :reply_to_id, :reactions, :attachments, :importance,
|
|
10
|
-
:edited, :mentions
|
|
10
|
+
:edited, :mentions, :images
|
|
11
11
|
) do
|
|
12
12
|
extend Parsing
|
|
13
13
|
|
|
14
|
+
# `images` (inline AMS images) defaults to empty so callers can omit it
|
|
15
|
+
def initialize(images: [], **attrs) = super
|
|
16
|
+
|
|
14
17
|
def self.from_api(data)
|
|
15
18
|
if data['message'] then from_teams_internal_api(data)
|
|
16
19
|
elsif data['imdisplayname'] || data['messagetype'] then from_ng_msg_api(data)
|
|
@@ -27,7 +30,7 @@ module Teems
|
|
|
27
30
|
{
|
|
28
31
|
id: data['id'], sender_id: data['from'],
|
|
29
32
|
sender_name: data['imdisplayname'] || data['fromDisplayNameInToken'] || 'Unknown',
|
|
30
|
-
|
|
33
|
+
**html_body_attrs(data['content']),
|
|
31
34
|
created_at: parse_time(data['composetime'] || data['originalarrivaltime']),
|
|
32
35
|
message_type: data['messagetype'],
|
|
33
36
|
**ng_msg_extras(data, props)
|
|
@@ -57,7 +60,7 @@ module Teems
|
|
|
57
60
|
new(
|
|
58
61
|
id: data['id'], sender_id: msg['from'],
|
|
59
62
|
sender_name: msg['imDisplayName'] || msg['fromDisplayNameInToken'] || 'Unknown',
|
|
60
|
-
|
|
63
|
+
**html_body_attrs(msg['content']),
|
|
61
64
|
created_at: parse_time(msg['composeTime'] || data['latestMessageTime']),
|
|
62
65
|
message_type: msg['type'], reply_to_id: nil, reactions: [],
|
|
63
66
|
attachments: parse_files_json(msg.dig('properties', 'files')),
|
|
@@ -74,7 +77,7 @@ module Teems
|
|
|
74
77
|
id: data['id'],
|
|
75
78
|
sender_id: data.dig('from', 'user', 'id') || data.dig('from', 'application', 'id'),
|
|
76
79
|
sender_name: extract_sender_name(data),
|
|
77
|
-
|
|
80
|
+
**html_body_attrs(data.dig('body', 'content')),
|
|
78
81
|
created_at: parse_time(data['createdDateTime']),
|
|
79
82
|
**graph_extras(data)
|
|
80
83
|
}
|
data/lib/teems/models/parsing.rb
CHANGED
|
@@ -13,6 +13,11 @@ module Teems
|
|
|
13
13
|
CGI.unescapeHTML(html.gsub(/<[^>]+>/, ' ')).gsub(' ', ' ').gsub(/\s+/, ' ').strip
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Plain-text content plus the inline images that strip_html would discard
|
|
17
|
+
def html_body_attrs(html)
|
|
18
|
+
{ content: strip_html(html || ''), images: InlineImage.parse_html(html) }
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def parse_time(time_str)
|
|
17
22
|
return nil unless time_str
|
|
18
23
|
|
data/lib/teems/runner.rb
CHANGED
|
@@ -72,6 +72,11 @@ module Teems
|
|
|
72
72
|
Services::TokenRefresher.new(token_store: @token_store, output: @output)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Downloader for inline message images; reads the current skype token on each request
|
|
76
|
+
def inline_image_downloader
|
|
77
|
+
Services::InlineImageDownloader.new(token_provider: -> { account.skype_token })
|
|
78
|
+
end
|
|
79
|
+
|
|
75
80
|
# Attempt to refresh the skype_token
|
|
76
81
|
def refresh_tokens
|
|
77
82
|
token_refresher.refresh
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Error responses from AMS, mapped so the caller can fall back or refresh tokens
|
|
6
|
+
module InlineImageErrors
|
|
7
|
+
# The requested AMS view does not exist for this object (try another view)
|
|
8
|
+
class ViewUnavailable < ApiError; end
|
|
9
|
+
|
|
10
|
+
MISSING_VIEW_CODES = %w[400 404 410].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def raise_failure(response)
|
|
15
|
+
code = response.code
|
|
16
|
+
status = code.to_i
|
|
17
|
+
raise ApiError.new('Invalid token or session expired', status_code: 401) if status == 401
|
|
18
|
+
raise ViewUnavailable.new("HTTP #{code}", status_code: status) if MISSING_VIEW_CODES.include?(code)
|
|
19
|
+
|
|
20
|
+
raise ApiError.new("HTTP #{code}", status_code: status)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def untrusted_host!(uri)
|
|
24
|
+
raise Error, "Refusing to send Teams credentials to untrusted image host: #{uri.host}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Identifies image bytes by their magic number
|
|
29
|
+
module ImageSignature
|
|
30
|
+
SIGNATURES = { 'png' => "\x89PNG".b, 'jpg' => "\xFF\xD8\xFF".b, 'gif' => 'GIF8'.b }.freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def extension_for(body)
|
|
35
|
+
bytes = body.to_s.b
|
|
36
|
+
found = SIGNATURES.find { |_ext, magic| bytes.start_with?(magic) }&.first
|
|
37
|
+
found || ('webp' if bytes.start_with?('RIFF') && bytes.byteslice(8, 4) == 'WEBP')
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fetches inline message images from Teams' AMS object store. AMS accepts the
|
|
42
|
+
# skype token as `Authorization: skype_token <token>` (Bearer tokens get a 401).
|
|
43
|
+
# Credentials are only sent over HTTPS to Microsoft Teams/Skype hosts.
|
|
44
|
+
class InlineImageDownloader
|
|
45
|
+
include InlineImageErrors
|
|
46
|
+
|
|
47
|
+
# Downloaded image bytes, the file extension they imply, and the URL that served them
|
|
48
|
+
Result = Data.define(:body, :extension, :url)
|
|
49
|
+
|
|
50
|
+
TRUSTED_HOST = /(?:\A|\.)(?:asm\.skype\.com|teams\.microsoft\.com|teams\.microsoft\.us)\z/i
|
|
51
|
+
MAX_REDIRECTS = 3
|
|
52
|
+
TIMEOUTS = { open_timeout: 10, read_timeout: 60 }.freeze
|
|
53
|
+
|
|
54
|
+
# token_provider is called per request so a refreshed skype token is picked up
|
|
55
|
+
def initialize(token_provider:, http_client: nil)
|
|
56
|
+
@token_provider = token_provider
|
|
57
|
+
@http_client = http_client
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prefers the original upload and falls back to the `src` preview when that view is missing
|
|
61
|
+
def fetch(image)
|
|
62
|
+
full_size = image.full_size_url
|
|
63
|
+
preview = image.url
|
|
64
|
+
fetch_url(full_size)
|
|
65
|
+
rescue ViewUnavailable
|
|
66
|
+
raise if full_size == preview
|
|
67
|
+
|
|
68
|
+
fetch_url(preview)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.trusted?(uri) = uri.is_a?(URI::HTTPS) && TRUSTED_HOST.match?(uri.host.to_s)
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def fetch_url(url)
|
|
76
|
+
uri = URI(url)
|
|
77
|
+
untrusted_host!(uri) unless self.class.trusted?(uri)
|
|
78
|
+
|
|
79
|
+
follow(uri, MAX_REDIRECTS)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def follow(uri, redirects_left)
|
|
83
|
+
response = http_get(uri)
|
|
84
|
+
case response
|
|
85
|
+
when Net::HTTPSuccess then build_result(response.body, uri)
|
|
86
|
+
when Net::HTTPRedirection then follow_redirect(uri, response['location'], redirects_left)
|
|
87
|
+
else raise_failure(response)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def follow_redirect(uri, location, redirects_left)
|
|
92
|
+
raise Error, 'Too many redirects' unless redirects_left.positive? && location
|
|
93
|
+
|
|
94
|
+
follow(uri + location, redirects_left - 1)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_result(body, uri)
|
|
98
|
+
extension = ImageSignature.extension_for(body)
|
|
99
|
+
raise Error, 'Response was not an image' unless extension
|
|
100
|
+
|
|
101
|
+
Result.new(body: body.to_s, extension: extension, url: uri.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Redirect targets outside Teams/Skype (e.g. a signed CDN URL) get no credentials
|
|
105
|
+
def http_get(uri)
|
|
106
|
+
headers = self.class.trusted?(uri) ? { 'Authorization' => "skype_token #{@token_provider.call}" } : {}
|
|
107
|
+
return @http_client.call(uri, headers) if @http_client
|
|
108
|
+
|
|
109
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true, **TIMEOUTS) do |http|
|
|
110
|
+
http.request(Net::HTTP::Get.new(uri, headers))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -23,7 +23,8 @@ module Teems
|
|
|
23
23
|
{ 'type' => reaction[:type], 'count' => reaction[:count] }
|
|
24
24
|
end,
|
|
25
25
|
'attachments' => message.attachments, 'importance' => message.importance,
|
|
26
|
-
'edited' => message.edited, 'mentions' => message.mentions
|
|
26
|
+
'edited' => message.edited, 'mentions' => message.mentions,
|
|
27
|
+
'images' => message.images.map(&:as_json)
|
|
27
28
|
}
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -49,7 +50,8 @@ module Teems
|
|
|
49
50
|
attachments: stored_default(data, 'attachments', []),
|
|
50
51
|
importance: data['importance'],
|
|
51
52
|
edited: stored_default(data, 'edited', false),
|
|
52
|
-
mentions: stored_default(data, 'mentions', [])
|
|
53
|
+
mentions: stored_default(data, 'mentions', []),
|
|
54
|
+
images: Array(data['images']).filter_map { |image| Models::InlineImage.from_h(image) } }
|
|
53
55
|
end
|
|
54
56
|
|
|
55
57
|
def stored_default(data, key, fallback) = data[key] || fallback
|
|
@@ -140,10 +142,11 @@ module Teems
|
|
|
140
142
|
filter_and_sort_messages(messages, start_time)
|
|
141
143
|
end
|
|
142
144
|
|
|
143
|
-
|
|
145
|
+
# Yields (chat_dir, merged_messages) before writing, e.g. to save inline images first
|
|
146
|
+
def merge_and_write(chat, existing_raw, new_messages, &)
|
|
144
147
|
existing = existing_raw.map { |data| message_from_stored(data) }
|
|
145
148
|
all_messages = merge_messages(existing, new_messages)
|
|
146
|
-
write_chat_files(chat, all_messages)
|
|
149
|
+
write_chat_files(chat, all_messages, &)
|
|
147
150
|
all_messages
|
|
148
151
|
end
|
|
149
152
|
|
|
@@ -172,10 +175,13 @@ module Teems
|
|
|
172
175
|
def index_by_id(messages) = messages.to_h { |msg| [msg.id, msg] }
|
|
173
176
|
|
|
174
177
|
def write_chat_files(chat, messages)
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
chat_id = chat.id
|
|
179
|
+
chat_dir = @sync_store.chat_dir(chat_id, state: @state)
|
|
180
|
+
yield(chat_dir, messages) if block_given?
|
|
181
|
+
fmt = Formatters::MarkdownFormatter.new(chat_name: chat.display_name, chat_type: chat.chat_type,
|
|
182
|
+
synced_at: Time.now, image_link: SyncImages.link_resolver(chat_dir))
|
|
177
183
|
json = JSON.pretty_generate(messages.map { |msg| message_to_hash(msg) })
|
|
178
|
-
@sync_store.write_messages(
|
|
184
|
+
@sync_store.write_messages(chat_id, messages_md: fmt.format(messages), messages_json: json, state: @state)
|
|
179
185
|
write_metadata(chat)
|
|
180
186
|
end
|
|
181
187
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Inline images saved beside a synced chat (`teems sync --images`) and linked from messages.md.
|
|
6
|
+
# Files are named by AMS object id, so images already on disk are never fetched again.
|
|
7
|
+
class SyncImages
|
|
8
|
+
DIR = 'images'
|
|
9
|
+
|
|
10
|
+
# { file stem => file name } for the images already saved for a chat
|
|
11
|
+
def self.saved(chat_dir)
|
|
12
|
+
dir = File.join(chat_dir, DIR)
|
|
13
|
+
return {} unless File.directory?(dir)
|
|
14
|
+
|
|
15
|
+
Dir.children(dir).reject { |name| name.end_with?('.tmp') }.to_h { |name| [File.basename(name, '.*'), name] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Callable for MarkdownFormatter: relative path of a saved image, or nil
|
|
19
|
+
def self.link_resolver(chat_dir)
|
|
20
|
+
saved = saved(chat_dir)
|
|
21
|
+
lambda do |image|
|
|
22
|
+
name = saved[image.file_stem]
|
|
23
|
+
"#{DIR}/#{name}" if name
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(downloader:, output: nil)
|
|
28
|
+
@downloader = downloader
|
|
29
|
+
@output = output
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Downloads images not yet on disk; returns how many were saved
|
|
33
|
+
def save(chat_dir, messages)
|
|
34
|
+
existing = self.class.saved(chat_dir)
|
|
35
|
+
pending = messages.flat_map(&:images).uniq(&:file_stem).reject { |image| existing.key?(image.file_stem) }
|
|
36
|
+
pending.sum { |image| save_image(File.join(chat_dir, DIR), image) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def save_image(dir, image)
|
|
42
|
+
stem = image.file_stem
|
|
43
|
+
result = @downloader.fetch(image)
|
|
44
|
+
FileUtils.mkdir_p(dir)
|
|
45
|
+
path = File.join(dir, "#{stem}.#{result.extension}")
|
|
46
|
+
File.binwrite("#{path}.tmp", result.body)
|
|
47
|
+
File.rename("#{path}.tmp", path)
|
|
48
|
+
1
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
@output&.warn(" Could not download image #{stem}: #{e.message}")
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -17,6 +17,8 @@ module Teems
|
|
|
17
17
|
module Subprocess
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
|
+
# Named after Open3.capture3, which it wraps with the same signature and return value
|
|
21
|
+
# :reek:UncommunicativeMethodName
|
|
20
22
|
def capture3(*command)
|
|
21
23
|
previous = Thread.report_on_exception
|
|
22
24
|
Thread.report_on_exception = false
|
data/lib/teems/version.rb
CHANGED
data/lib/teems.rb
CHANGED
|
@@ -49,6 +49,8 @@ module Teems
|
|
|
49
49
|
autoload :Chat, 'teems/models/chat'
|
|
50
50
|
autoload :Event, 'teems/models/event'
|
|
51
51
|
autoload :Message, 'teems/models/message'
|
|
52
|
+
autoload :InlineImage, 'teems/models/inline_image'
|
|
53
|
+
autoload :InlineImageParsing, 'teems/models/inline_image'
|
|
52
54
|
autoload :Parsing, 'teems/models/parsing'
|
|
53
55
|
autoload :User, 'teems/models/user'
|
|
54
56
|
autoload :UserProfile, 'teems/models/user_profile'
|
|
@@ -64,11 +66,13 @@ module Teems
|
|
|
64
66
|
autoload :TokenRefresher, 'teems/services/token_refresher'
|
|
65
67
|
autoload :CacheStore, 'teems/services/cache_store'
|
|
66
68
|
autoload :FileDownloader, 'teems/services/file_downloader'
|
|
69
|
+
autoload :InlineImageDownloader, 'teems/services/inline_image_downloader'
|
|
67
70
|
autoload :TeamsUrlParser, 'teems/services/teams_url_parser'
|
|
68
71
|
autoload :SyncStore, 'teems/services/sync_store'
|
|
69
72
|
autoload :SyncDirNaming, 'teems/services/sync_dir_naming'
|
|
70
73
|
autoload :SafariJsRunner, 'teems/services/safari_js_runner'
|
|
71
74
|
autoload :SyncEngine, 'teems/services/sync_engine'
|
|
75
|
+
autoload :SyncImages, 'teems/services/sync_images'
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
# Output formatters for messages and terminal output
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: teems
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- lib/teems/models/chat.rb
|
|
63
63
|
- lib/teems/models/duration.rb
|
|
64
64
|
- lib/teems/models/event.rb
|
|
65
|
+
- lib/teems/models/inline_image.rb
|
|
65
66
|
- lib/teems/models/message.rb
|
|
66
67
|
- lib/teems/models/parsing.rb
|
|
67
68
|
- lib/teems/models/user.rb
|
|
@@ -72,10 +73,12 @@ files:
|
|
|
72
73
|
- lib/teems/services/configuration.rb
|
|
73
74
|
- lib/teems/services/file_downloader.rb
|
|
74
75
|
- lib/teems/services/headless_extract.rb
|
|
76
|
+
- lib/teems/services/inline_image_downloader.rb
|
|
75
77
|
- lib/teems/services/safari_js_runner.rb
|
|
76
78
|
- lib/teems/services/safari_oauth.rb
|
|
77
79
|
- lib/teems/services/sync_dir_naming.rb
|
|
78
80
|
- lib/teems/services/sync_engine.rb
|
|
81
|
+
- lib/teems/services/sync_images.rb
|
|
79
82
|
- lib/teems/services/sync_store.rb
|
|
80
83
|
- lib/teems/services/teams_url_parser.rb
|
|
81
84
|
- lib/teems/services/token_exchange_scripts.rb
|