teems 0.3.2 → 0.8.0
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 +78 -0
- data/README.md +84 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/meeting.rb +4 -3
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +45 -11
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/commands/transcripts.rb +205 -137
- data/lib/teems/formatters/format_utils.rb +26 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/formatters/transcript_markdown.rb +16 -12
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/inline_image.rb +83 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +11 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/inline_image_downloader.rb +115 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/subprocess.rb +2 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +67 -2
- metadata +51 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Formatters
|
|
5
|
+
# One line saying what an Inbox rule does, e.g.
|
|
6
|
+
# from jira@example.com, except subject has "urgent" -> move to Inbox/Jira, stop
|
|
7
|
+
# Keys this does not know are spelled out from their Graph names, so nothing is hidden.
|
|
8
|
+
class MailRuleText
|
|
9
|
+
LABELS = {
|
|
10
|
+
'fromAddresses' => 'from', 'sentToAddresses' => 'to', 'subjectContains' => 'subject has',
|
|
11
|
+
'bodyContains' => 'body has', 'bodyOrSubjectContains' => 'subject or body has',
|
|
12
|
+
'senderContains' => 'sender has', 'recipientContains' => 'recipient has',
|
|
13
|
+
'headerContains' => 'header has', 'moveToFolder' => 'move to', 'copyToFolder' => 'copy to',
|
|
14
|
+
'assignCategories' => 'categories', 'markImportance' => 'importance',
|
|
15
|
+
'forwardTo' => 'FORWARD to', 'forwardAsAttachmentTo' => 'FORWARD as attachment to',
|
|
16
|
+
'redirectTo' => 'REDIRECT to'
|
|
17
|
+
}.freeze
|
|
18
|
+
# Conditions and actions that are simply on
|
|
19
|
+
SWITCHES = { 'markAsRead' => 'mark read', 'stopProcessingRules' => 'stop',
|
|
20
|
+
'delete' => 'delete', 'permanentDelete' => 'PERMANENTLY delete',
|
|
21
|
+
'isMeetingRequest' => 'meeting requests', 'hasAttachments' => 'with attachments' }.freeze
|
|
22
|
+
FOLDER_ACTIONS = %w[moveToFolder copyToFolder].freeze
|
|
23
|
+
|
|
24
|
+
# folder_names: Graph folder ID => path, to name a rule's destination
|
|
25
|
+
def initialize(folder_names = {})
|
|
26
|
+
@folder_names = folder_names
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def describe(rule)
|
|
30
|
+
exceptions = phrases(rule.exceptions)
|
|
31
|
+
tests = phrases(rule.conditions).join(', ')
|
|
32
|
+
tests = 'all mail' if tests.empty?
|
|
33
|
+
tests += ", except #{exceptions.join(', ')}" if exceptions.any?
|
|
34
|
+
"#{tests} -> #{phrases(rule.actions).join(', ')}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def phrases(settings) = settings.filter_map { |key, value| phrase(key, value) }
|
|
40
|
+
|
|
41
|
+
def phrase(key, value)
|
|
42
|
+
return if [false, nil, []].include?(value)
|
|
43
|
+
|
|
44
|
+
switch = value == true
|
|
45
|
+
label = (switch ? SWITCHES : LABELS).fetch(key) { spelled(key) }
|
|
46
|
+
switch ? label : "#{label} #{values_text(key, value)}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def values_text(key, value) = Array(value).map { |item| item_text(key, item) }.join(', ')
|
|
50
|
+
|
|
51
|
+
def item_text(key, item)
|
|
52
|
+
return @folder_names.fetch(item, 'another folder') if FOLDER_ACTIONS.include?(key)
|
|
53
|
+
return recipient_text(item) if item.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
key.end_with?('Contains') ? %("#{item}") : item.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def recipient_text(item)
|
|
59
|
+
address = item['emailAddress'] || item
|
|
60
|
+
address.values_at('address', 'name').compact.first.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# isAutomaticReply -> is automatic reply
|
|
64
|
+
def spelled(key) = key.gsub(/[A-Z]/) { |letter| " #{letter.downcase}" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -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?
|
|
@@ -22,6 +22,14 @@ module Teems
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Stands in for stdout or stderr once its reader has gone away (see Output#to_stdout)
|
|
26
|
+
module NullIO
|
|
27
|
+
def self.puts(*) = nil
|
|
28
|
+
def self.print(*) = nil
|
|
29
|
+
def self.flush = self
|
|
30
|
+
def self.tty? = false
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
# Terminal output with ANSI color support
|
|
26
34
|
class Output
|
|
27
35
|
include OutputColors
|
|
@@ -52,23 +60,34 @@ module Teems
|
|
|
52
60
|
def tty? = @io.tty?
|
|
53
61
|
|
|
54
62
|
def puts(message = '')
|
|
55
|
-
write_unless_quiet {
|
|
63
|
+
write_unless_quiet { |io| io.puts(message) }
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
def print(message)
|
|
59
|
-
write_unless_quiet {
|
|
67
|
+
write_unless_quiet { |io| io.print(message) }
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
def flush
|
|
63
|
-
|
|
71
|
+
to_stdout(&:flush)
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def error(message)
|
|
67
|
-
|
|
75
|
+
to_stderr { |err| err.puts(colorize("#{red('Error:')} #{message}")) }
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
def warn(message)
|
|
71
|
-
|
|
79
|
+
note("#{yellow('Warning:')} #{message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A question for the person at the terminal: on stderr, so piped stdout stays data, and
|
|
83
|
+
# shown under --quiet too, since an unseen question would just hang (stderr is unbuffered)
|
|
84
|
+
def prompt(message)
|
|
85
|
+
to_stderr { |err| err.print(message) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Commentary that should not mix with data on stdout (shown unless --quiet)
|
|
89
|
+
def note(message)
|
|
90
|
+
to_stderr { |err| err.puts(colorize(message)) } unless quiet?
|
|
72
91
|
end
|
|
73
92
|
|
|
74
93
|
def success(message)
|
|
@@ -82,13 +101,31 @@ module Teems
|
|
|
82
101
|
def debug(message)
|
|
83
102
|
return unless verbose?
|
|
84
103
|
|
|
85
|
-
|
|
104
|
+
to_stderr { |err| err.puts(colorize("#{gray('[debug]')} #{message}")) }
|
|
86
105
|
end
|
|
87
106
|
|
|
88
107
|
private
|
|
89
108
|
|
|
90
|
-
def write_unless_quiet
|
|
91
|
-
|
|
109
|
+
def write_unless_quiet(&)
|
|
110
|
+
to_stdout(&) unless quiet?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A reader that stops early (`teems ... | head`) is not an error: drop the
|
|
114
|
+
# rest of stdout and let the command finish quietly. Raising instead would
|
|
115
|
+
# surface as "Unexpected error: Broken pipe", or be caught by a per-item
|
|
116
|
+
# rescue and reported as a failed chat or download.
|
|
117
|
+
def to_stdout
|
|
118
|
+
yield @io
|
|
119
|
+
rescue Errno::EPIPE
|
|
120
|
+
@io = NullIO
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The same for stderr, which `teems ... 2>&1 | head` closes along with stdout. A
|
|
124
|
+
# warning that raised here would also escape the per-item rescues that report it.
|
|
125
|
+
def to_stderr
|
|
126
|
+
yield @err
|
|
127
|
+
rescue Errno::EPIPE
|
|
128
|
+
@err = NullIO
|
|
92
129
|
end
|
|
93
130
|
|
|
94
131
|
def wrap(color, text)
|
|
@@ -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
|
data/lib/teems/models/chat.rb
CHANGED
|
@@ -30,18 +30,22 @@ module Teems
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def self.from_ngmsg(data)
|
|
33
|
-
|
|
33
|
+
id = data['id']
|
|
34
34
|
props = data['properties'] || {}
|
|
35
35
|
new(
|
|
36
|
-
id:
|
|
37
|
-
topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
|
|
38
|
-
chat_type: normalize_chat_type(thread_props['threadType']),
|
|
39
|
-
created_at: parse_time(thread_props['createdat']),
|
|
36
|
+
id: id,
|
|
40
37
|
last_updated: parse_time(props['lastimreceivedtime']),
|
|
38
|
+
**ngmsg_thread(id, data['threadProperties'] || {}),
|
|
41
39
|
**ngmsg_status(props, data['lastMessage'])
|
|
42
40
|
)
|
|
43
41
|
end
|
|
44
42
|
|
|
43
|
+
def self.ngmsg_thread(id, thread_props)
|
|
44
|
+
{ topic: thread_props['topic'] || thread_props['spaceThreadTopic'],
|
|
45
|
+
chat_type: ngmsg_chat_type(id, thread_props),
|
|
46
|
+
created_at: parse_time(thread_props['createdat']) }
|
|
47
|
+
end
|
|
48
|
+
|
|
45
49
|
def self.ngmsg_status(props, last_message)
|
|
46
50
|
horizon = props['consumptionhorizon']
|
|
47
51
|
last_msg_id = last_message&.dig('id')
|
|
@@ -52,6 +56,13 @@ module Teems
|
|
|
52
56
|
}
|
|
53
57
|
end
|
|
54
58
|
|
|
59
|
+
# ng.msg reports 1:1 chats with threadType 'chat', like group chats. They are marked by
|
|
60
|
+
# productThreadType 'OneToOneChat' and a "19:<user>_<user>@unq.gbl.spaces" ID.
|
|
61
|
+
def self.ngmsg_chat_type(id, thread_props)
|
|
62
|
+
one_on_one = thread_props['productThreadType'] == 'OneToOneChat' || id.to_s.end_with?('@unq.gbl.spaces')
|
|
63
|
+
one_on_one ? 'oneOnOne' : normalize_chat_type(thread_props['threadType'])
|
|
64
|
+
end
|
|
65
|
+
|
|
55
66
|
# Normalize ng.msg threadType to a consistent chat type.
|
|
56
67
|
# ng.msg uses: 'chat' (group), 'meeting', 'topic' (channel), 'space'
|
|
57
68
|
# Normalized to: 'group', 'meeting', 'channel', 'space'
|
|
@@ -79,6 +90,9 @@ module Teems
|
|
|
79
90
|
|
|
80
91
|
def chat_type_label = CHAT_TYPE_LABELS.fetch(chat_type, chat_type)
|
|
81
92
|
|
|
93
|
+
# What sync uses to name and place the chat's directory
|
|
94
|
+
def sync_identity = { chat_id: id, display_name: display_name, chat_type: chat_type }
|
|
95
|
+
|
|
82
96
|
def unread? = unread
|
|
83
97
|
def favorite? = favorite
|
|
84
98
|
def pinned? = pinned
|
data/lib/teems/models/event.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Teems
|
|
|
24
24
|
:event_type
|
|
25
25
|
) do
|
|
26
26
|
extend Parsing
|
|
27
|
+
include EventDisplay
|
|
27
28
|
|
|
28
29
|
def self.from_api(data)
|
|
29
30
|
new(**event_attrs(data))
|
|
@@ -90,29 +91,6 @@ module Teems
|
|
|
90
91
|
def cancelled? = is_cancelled
|
|
91
92
|
def recurring? = %w[occurrence exception].include?(event_type)
|
|
92
93
|
|
|
93
|
-
def time_range_display
|
|
94
|
-
return 'ALL DAY' if all_day?
|
|
95
|
-
return '' unless start_time && end_time
|
|
96
|
-
|
|
97
|
-
"#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def date_display
|
|
101
|
-
if all_day?
|
|
102
|
-
"#{start_time&.strftime('%Y-%m-%d')} (all day)"
|
|
103
|
-
elsif start_time && end_time
|
|
104
|
-
"#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def create_summary_lines
|
|
109
|
-
lines = []
|
|
110
|
-
lines << date_display if date_display
|
|
111
|
-
lines << "Location: #{location}" if location && !location.empty?
|
|
112
|
-
lines << "Teams link: #{online_meeting_url}" if online_meeting_url
|
|
113
|
-
lines
|
|
114
|
-
end
|
|
115
|
-
|
|
116
94
|
def required_attendees = attendees.select { |att| att[:type] == 'required' }
|
|
117
95
|
def optional_attendees = attendees.select { |att| att[:type] == 'optional' }
|
|
118
96
|
def accepted_attendees = attendees.select { |att| att[:response] == 'accepted' }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# Date and time labels for an Event (expects start_time, end_time, all_day?, location, online_meeting_url)
|
|
6
|
+
module EventDisplay
|
|
7
|
+
def time_range_display
|
|
8
|
+
return 'ALL DAY' if all_day?
|
|
9
|
+
return '' unless start_time && end_time
|
|
10
|
+
|
|
11
|
+
"#{start_time.strftime('%H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def date_display
|
|
15
|
+
if all_day?
|
|
16
|
+
"#{all_day_span} (all day)"
|
|
17
|
+
elsif start_time && end_time
|
|
18
|
+
"#{start_time.strftime('%Y-%m-%d %H:%M')}-#{end_time.strftime('%H:%M')}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Graph ends all-day events at midnight after the last day, so a span is "first to last"
|
|
23
|
+
def all_day_span
|
|
24
|
+
first_day = start_time&.to_date
|
|
25
|
+
last_day = end_time&.to_date&.prev_day
|
|
26
|
+
return first_day.to_s unless first_day && last_day && last_day > first_day
|
|
27
|
+
|
|
28
|
+
"#{first_day} to #{last_day}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_summary_lines
|
|
32
|
+
lines = []
|
|
33
|
+
lines << date_display if date_display
|
|
34
|
+
lines << "Location: #{location}" if location && !location.empty?
|
|
35
|
+
lines << "Teams link: #{online_meeting_url}" if online_meeting_url
|
|
36
|
+
lines
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A calendarView event as `teems cal ics` exports it. Built from Graph data requested
|
|
6
|
+
# with Prefer: outlook.timezone="UTC", so timed events carry UTC times and all-day
|
|
7
|
+
# events carry their (floating) dates.
|
|
8
|
+
IcsEvent = Data.define(:uid, :subject, :starts, :ends, :all_day, :location, :organizer, :attendees,
|
|
9
|
+
:body, :join_url, :status) do
|
|
10
|
+
def self.from_api(data)
|
|
11
|
+
new(uid: data['iCalUId'] || data['id'], subject: data['subject'].to_s, **time_attrs(data),
|
|
12
|
+
location: data.dig('location', 'displayName').to_s, organizer: Event.parse_organizer(data['organizer']),
|
|
13
|
+
attendees: Event.parse_attendees(data['attendees']), body: data['body'] || {},
|
|
14
|
+
join_url: data.dig('onlineMeeting', 'joinUrl').to_s, status: status_attrs(data))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Dates for all-day events (Graph sends their midnights without a zone), UTC Times otherwise
|
|
18
|
+
def self.time_attrs(data)
|
|
19
|
+
all_day = data['isAllDay'] || false
|
|
20
|
+
parser = all_day ? :graph_date : :graph_utc
|
|
21
|
+
{ starts: send(parser, data['start']), ends: send(parser, data['end']), all_day: all_day }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.graph_date(value) = graph_stamp(value) { |stamp| Date.parse(stamp) }
|
|
25
|
+
|
|
26
|
+
def self.graph_utc(value) = graph_stamp(value) { |stamp| Time.parse("#{stamp.delete_suffix('Z')}Z").utc }
|
|
27
|
+
|
|
28
|
+
def self.graph_stamp(value)
|
|
29
|
+
stamp = Hash(value)['dateTime']
|
|
30
|
+
stamp && yield(stamp)
|
|
31
|
+
rescue ArgumentError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.status_attrs(data) = { cancelled: data['isCancelled'] || false, show_as: data['showAs'].to_s }
|
|
36
|
+
|
|
37
|
+
def dated? = [starts, ends].all?
|
|
38
|
+
|
|
39
|
+
def cancelled? = status[:cancelled]
|
|
40
|
+
|
|
41
|
+
def free? = status[:show_as] == 'free'
|
|
42
|
+
|
|
43
|
+
# Plain-text body, links listed at the end
|
|
44
|
+
def description
|
|
45
|
+
content = body['content'].to_s
|
|
46
|
+
body['contentType'] == 'html' ? Formatters::HtmlText.convert(content).to_s : content.strip
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True when one of addresses organizes the event or has accepted it, so it is already
|
|
50
|
+
# on that address's own calendar. An invitation alone does not count.
|
|
51
|
+
def involves?(addresses)
|
|
52
|
+
return false if addresses.empty?
|
|
53
|
+
|
|
54
|
+
accepted = attendees.select { |attendee| attendee[:response] == 'accepted' }
|
|
55
|
+
[Hash(organizer)[:email], *accepted.map { |attendee| attendee[:email] }]
|
|
56
|
+
.any? { |email| addresses.include?(email.to_s.downcase) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# A conversation's messages (oldest first), Graph folder ID => path for naming the folder
|
|
6
|
+
# each one sits in (a thread spans the Inbox, Sent Items, Archive and so on), message ID =>
|
|
7
|
+
# attachment names once with_attachment_names has fetched them, and message ID => how a
|
|
8
|
+
# decrypted message was opened (see MailEncryption)
|
|
9
|
+
MailConversation = Data.define(:messages, :folders, :attachments, :encryptions) do
|
|
10
|
+
def initialize(messages:, folders:, attachments: {}, encryptions: {}) = super
|
|
11
|
+
|
|
12
|
+
def folder(message) = folders[message.folder_id]
|
|
13
|
+
|
|
14
|
+
def with_attachment_names(api)
|
|
15
|
+
with(attachments: messages.to_h { |message| [message.id, message.attachment_names_from(api)] })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Marks these messages S/MIME encrypted and not opened; the envelope is not listed as a file
|
|
19
|
+
def with_locked(message_ids)
|
|
20
|
+
no_files = message_ids.to_h { |id| [id, []] }
|
|
21
|
+
locked = no_files.transform_values { { type: 'smime', decrypted: false } }
|
|
22
|
+
with(encryptions: encryptions.merge(locked), attachments: attachments.merge(no_files))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# opened: message ID => Services::SmimeContent::Result for each message decrypted
|
|
26
|
+
def with_decrypted(opened)
|
|
27
|
+
with(messages: messages.map { |message| opened[message.id]&.apply_to(message) || message },
|
|
28
|
+
attachments: attachments.merge(opened.transform_values(&:attachment_names)),
|
|
29
|
+
encryptions: encryptions.merge(opened.transform_values { |content| MailEncryption.decrypted(content) }))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Each message's JSON (from the block) with its folder path
|
|
33
|
+
def json = messages.map { |message| yield(message).merge(folder: folder(message)) }
|
|
34
|
+
|
|
35
|
+
# As `read --json` gives each message, plus its folder
|
|
36
|
+
def full_json
|
|
37
|
+
json do |message|
|
|
38
|
+
encryption = encryption(message)
|
|
39
|
+
hash = message.full_json_hash(attachments: names(message))
|
|
40
|
+
encryption ? hash.merge(encryption: encryption) : hash
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# As `read` prints each message, with its folder (and how it was protected) before the ID
|
|
45
|
+
def texts = messages.map { |message| message.to_text(attachments: names(message), notes: notes(message)) }
|
|
46
|
+
|
|
47
|
+
# list's tab-separated columns plus the folder path
|
|
48
|
+
def tsv_rows = messages.map { |message| "#{message.tsv_row}\t#{folder(message).to_s.tr("\t", ' ')}" }
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def names(message) = attachments.fetch(message.id, [])
|
|
53
|
+
|
|
54
|
+
def encryption(message) = message.encryption_in(encryptions)
|
|
55
|
+
|
|
56
|
+
def notes(message)
|
|
57
|
+
protection = MailEncryption.notes(encryption(message), web_link: message.web_link)
|
|
58
|
+
{ 'Folder' => folder(message) }.compact.merge(protection)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Models
|
|
5
|
+
# How protected mail was shown, as { type: 'smime' | 'purview', decrypted:, signed: } (signed
|
|
6
|
+
# only once decrypted), and the header lines `read` and `thread --full` print for it. Purview
|
|
7
|
+
# mail gets its Outlook on the web link, where Exchange decrypts it for you.
|
|
8
|
+
module MailEncryption
|
|
9
|
+
LOCKED_HINT = 'S/MIME encrypted; read it with --decrypt (macOS asks for your PIV PIN)'
|
|
10
|
+
|
|
11
|
+
# 'purview' (rights-managed, Content-Class rpmsg.message), 'smime' (an S/MIME envelope),
|
|
12
|
+
# or nil, from a message's internet headers. Drafts have none, and Exchange wraps most
|
|
13
|
+
# S/MIME mail as TNEF (see envelope?), so nil is not proof of plain mail.
|
|
14
|
+
def self.from_headers(headers)
|
|
15
|
+
fields = Array(headers).to_h { |header| [header['name'].to_s.downcase, header['value'].to_s.downcase] }
|
|
16
|
+
return 'purview' if fields['content-class'].to_s.start_with?('rpmsg.message')
|
|
17
|
+
|
|
18
|
+
type, params = Support::MimeHeader.field(fields['content-type'])
|
|
19
|
+
'smime' if Support::MimePart::PKCS7_TYPES.include?(type) && params['smime-type'] != 'signed-data'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.notes(encryption, web_link: nil)
|
|
23
|
+
return {} unless encryption
|
|
24
|
+
|
|
25
|
+
type = encryption[:type]
|
|
26
|
+
return { 'Encryption' => MailMessage::PROTECTION_NOTES[type], 'Open' => web_link }.compact if type == 'purview'
|
|
27
|
+
return { 'Encryption' => LOCKED_HINT } unless encryption[:decrypted]
|
|
28
|
+
|
|
29
|
+
{ 'Encryption' => 'S/MIME, decrypted', 'Signature' => encryption[:signed] ? 'Signed (not checked)' : '' }
|
|
30
|
+
.reject { |_name, value| value.empty? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.decrypted(content) = { type: 'smime', decrypted: true, signed: content.signed }
|
|
34
|
+
|
|
35
|
+
# An S/MIME envelope (smime.p7m) among a message's Graph attachments
|
|
36
|
+
def self.envelope?(attachment)
|
|
37
|
+
Support::MimePart::PKCS7_TYPES.include?(Support::MimeHeader.field(attachment['contentType']).first)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|