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,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Keeps every chat directory owned by exactly one chat.
|
|
6
|
+
#
|
|
7
|
+
# Older teems versions could give several chats the same directory (see SyncDirNaming), and
|
|
8
|
+
# syncing then merged their messages there. Stored messages carry no chat ID, so a shared
|
|
9
|
+
# directory can't be split back into its chats reliably. Instead it is detached: the directory
|
|
10
|
+
# and its files are left untouched, and each chat that used it forgets it (recording the path
|
|
11
|
+
# under 'legacy_shared_dir') and re-syncs into its own directory, starting from the oldest
|
|
12
|
+
# message in the shared one ('resync_from') so no history is left behind only there.
|
|
13
|
+
module SyncDirOwnership
|
|
14
|
+
# One chat's claim on a directory relative to chats/. A detached claim (attached: false) is a
|
|
15
|
+
# directory the chat no longer syncs into but whose files still hold its older messages.
|
|
16
|
+
DirClaim = Data.define(:chat_id, :path, :attached) do
|
|
17
|
+
def key = SyncDirOwnership.fold(path)
|
|
18
|
+
|
|
19
|
+
# A chat's claims: the directory it syncs into, and the shared one it was detached from
|
|
20
|
+
def self.for_entry(chat_id, entry)
|
|
21
|
+
name, legacy = entry.values_at('dir_name', 'legacy_shared_dir')
|
|
22
|
+
own = new(chat_id, File.join(SyncDirNaming.type_dir(entry['chat_type']), name), true) if name
|
|
23
|
+
[own, (new(chat_id, legacy, false) if legacy)].compact
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Directory names compare case-insensitively and NFC-normalized, as on macOS and Windows
|
|
28
|
+
def self.fold(path) = path.unicode_normalize(:nfc).downcase(:fold)
|
|
29
|
+
|
|
30
|
+
# Directories that more than one chat has synced into, with the chats still syncing into
|
|
31
|
+
# them: { "groups/Group Chat (19_abc)" => [chat_id, ...] }. With only:, just that chat.
|
|
32
|
+
# Chats that never synced (404 on their first sync) don't count: nothing of theirs is there.
|
|
33
|
+
def shared_dirs(state, only: nil)
|
|
34
|
+
owner_claims(state.fetch('chats', {})).group_by(&:key).values.filter_map do |claims|
|
|
35
|
+
shared_dir_entry(claims, only)
|
|
36
|
+
end.to_h
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Detaches shared directories (see #shared_dirs) from their chats; returns what it detached
|
|
40
|
+
def detach_shared_dirs(state, only: nil)
|
|
41
|
+
shared = shared_dirs(state, only: only)
|
|
42
|
+
chats = state['chats']
|
|
43
|
+
oldest = shared.keys.to_h { |path| [path, oldest_message_time(path)] }
|
|
44
|
+
shared.flat_map { |path, ids| ids.product([path]) }.each do |id, path|
|
|
45
|
+
detach_entry(chats[id], path, oldest[path])
|
|
46
|
+
end
|
|
47
|
+
shared
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Directories chats were detached from that still exist: { path => [chat_id, ...] }
|
|
51
|
+
def legacy_shared_dirs(state)
|
|
52
|
+
detached = dir_claims(state.fetch('chats', {})).reject(&:attached)
|
|
53
|
+
existing = detached.group_by(&:path).select { |path, _claims| File.directory?(chats_path(path)) }
|
|
54
|
+
existing.transform_values { |claims| claims.map(&:chat_id) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where a detached chat's re-sync starts: the oldest message in the directory it left
|
|
58
|
+
def resync_from(state, chat_id) = parse_synced_at(state.dig('chats', chat_id, 'resync_from'))
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def owner_claims(chats) = dir_claims(chats.select { |_id, entry| ever_synced?(entry) })
|
|
63
|
+
|
|
64
|
+
def dir_claims(chats) = chats.flat_map { |id, entry| DirClaim.for_entry(id, entry) }
|
|
65
|
+
|
|
66
|
+
# Only update_chat_state writes the first two. last_synced_at alone isn't enough: cursors get
|
|
67
|
+
# cleared to force a re-fetch, which doesn't make the chat's existing files go away. A detached
|
|
68
|
+
# chat's older messages are in its legacy_shared_dir.
|
|
69
|
+
def ever_synced?(entry) = entry.values_at('last_synced_at', 'message_count', 'legacy_shared_dir').any?
|
|
70
|
+
|
|
71
|
+
def shared_dir_entry(claims, only)
|
|
72
|
+
return if claims.map(&:chat_id).uniq.one?
|
|
73
|
+
|
|
74
|
+
attached = claims.select(&:attached)
|
|
75
|
+
ids = attached.map(&:chat_id)
|
|
76
|
+
ids &= [only] if only
|
|
77
|
+
[attached.first.path, ids] unless ids.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Any claim by another chat blocks a new name, even one that never synced (it may be mid-run)
|
|
81
|
+
def dir_claimed?(others, path) = claims_dir?(dir_claims(others), path)
|
|
82
|
+
|
|
83
|
+
# Whether another chat has files in the directory, so it must not be moved or reused
|
|
84
|
+
def owned?(others, path) = claims_dir?(owner_claims(others), path)
|
|
85
|
+
|
|
86
|
+
def claims_dir?(claims, path)
|
|
87
|
+
key = SyncDirOwnership.fold(path)
|
|
88
|
+
claims.any? { |claim| claim.key == key }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fallback_dir_name(chat_id) = "#{sanitize_id(chat_id)}-#{Digest::SHA256.hexdigest(chat_id)[0, 8]}"
|
|
92
|
+
|
|
93
|
+
def detach_entry(entry, path, oldest)
|
|
94
|
+
entry.delete('dir_name')
|
|
95
|
+
entry.delete('last_synced_at')
|
|
96
|
+
entry['legacy_shared_dir'] = path
|
|
97
|
+
entry['resync_from'] = oldest.iso8601 if oldest
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Read-only on purpose: unlike load_json_or_default it never renames a corrupt file
|
|
101
|
+
def oldest_message_time(path)
|
|
102
|
+
messages = JSON.parse(File.read(File.join(chats_path(path), 'messages.json')))
|
|
103
|
+
Array(messages).filter_map { |msg| parse_synced_at(msg['created_at']) if msg.is_a?(Hash) }.min
|
|
104
|
+
rescue SystemCallError, JSON::ParserError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def chats_path(path) = File.join(sync_dir, SyncStore::CHATS_DIR, path)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Services
|
|
5
|
+
# Decides which directory each chat syncs into.
|
|
6
|
+
#
|
|
7
|
+
# Existing directory names are kept, so upgrading doesn't rename everything. A chat gets a new
|
|
8
|
+
# "<name> (<full ID>)" directory (see SyncDirNaming) only when it is new, was detached from a
|
|
9
|
+
# shared directory, or its display name or type changed. The name is checked against every
|
|
10
|
+
# other chat's directories when it is assigned, which is what prevents collisions.
|
|
11
|
+
#
|
|
12
|
+
# plan_chat_dir only updates state, so a dry run can preview every move; ensure_chat_dir also
|
|
13
|
+
# carries the move out on disk.
|
|
14
|
+
module SyncDirPlanning
|
|
15
|
+
# A chat's directory change, with paths relative to chats/. Kinds:
|
|
16
|
+
# :kept same directory as before
|
|
17
|
+
# :new first directory for the chat
|
|
18
|
+
# :split new directory for a chat detached from the shared directory in from
|
|
19
|
+
# :release new directory; from holds another chat's files too, so it stays put
|
|
20
|
+
# :move from will be (or was) renamed to to
|
|
21
|
+
# :repoint to already exists, so the chat switches to it and from is left in place
|
|
22
|
+
# :retarget from no longer exists (e.g. an interrupted run already moved it)
|
|
23
|
+
DirMove = Data.define(:chat_id, :from, :to, :kind) do
|
|
24
|
+
def to_s = "#{from} → #{to}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A chat's state entry plus the display name and type this sync found for it. A nil name or
|
|
28
|
+
# type means unknown (sync --chat has no chat metadata), so the stored one is kept.
|
|
29
|
+
ChatDirRequest = Data.define(:chat_id, :entry, :wanted, :migrate) do
|
|
30
|
+
def self.build(chats, chat_info)
|
|
31
|
+
chat_id = chat_info[:chat_id]
|
|
32
|
+
entry = chats[chat_id] ||= {}
|
|
33
|
+
wanted = { 'display_name' => chat_info[:display_name] || entry['display_name'],
|
|
34
|
+
'chat_type' => chat_info[:chat_type] || entry['chat_type'] }
|
|
35
|
+
new(chat_id, entry, wanted, chat_info[:migrate])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_dir
|
|
39
|
+
name = entry['dir_name']
|
|
40
|
+
dir_for(name, entry['chat_type']) if name
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# migrate: true treats every chat as changed, giving it a full-ID name
|
|
44
|
+
def unchanged? = !migrate && entry.slice('display_name', 'chat_type') == wanted
|
|
45
|
+
|
|
46
|
+
def display_name = wanted['display_name']
|
|
47
|
+
|
|
48
|
+
def wanted_dir(name) = dir_for(name, wanted['chat_type'])
|
|
49
|
+
|
|
50
|
+
def assign(name)
|
|
51
|
+
entry.merge!('dir_name' => name, 'chat_type' => wanted['chat_type'])
|
|
52
|
+
wanted_dir(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The move for a chat that had no directory: from is the shared one it was detached from
|
|
56
|
+
def first_move(to)
|
|
57
|
+
legacy = entry['legacy_shared_dir']
|
|
58
|
+
DirMove.new(chat_id, legacy, to, legacy ? :split : :new)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def dir_for(name, chat_type) = File.join(SyncDirNaming.type_dir(chat_type), name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Picks the chat's directory and records it in state without touching the filesystem
|
|
67
|
+
def plan_chat_dir(state, chat_info)
|
|
68
|
+
chat_id = chat_info[:chat_id]
|
|
69
|
+
chats = state['chats'] ||= {}
|
|
70
|
+
request = ChatDirRequest.build(chats, chat_info)
|
|
71
|
+
others = chats.except(chat_id)
|
|
72
|
+
from = request.current_dir
|
|
73
|
+
to = keep_dir?(request, others, from) ? from : assign_dir(request, others, chat_id)
|
|
74
|
+
from ? DirMove.new(chat_id, from, to, existing_dir_kind(others, from, to)) : request.first_move(to)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Like plan_chat_dir, then renames the old directory when that is safe. If the rename fails, the
|
|
78
|
+
# chat's entry is restored so state never points at a directory its files weren't moved to.
|
|
79
|
+
def ensure_chat_dir(state, chat_info:)
|
|
80
|
+
entry = (state['chats'] ||= {})[chat_info[:chat_id]] ||= {}
|
|
81
|
+
saved = entry.dup
|
|
82
|
+
move = plan_chat_dir(state, chat_info)
|
|
83
|
+
rename_chat_dir(move) if move.kind == :move
|
|
84
|
+
move
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
entry.replace(saved)
|
|
87
|
+
raise
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Records directory changes (paths relative to root) as dir-maps/<time>.json; returns its path
|
|
91
|
+
def write_dir_map(moves, time: Time.now)
|
|
92
|
+
dir = File.join(sync_dir, 'dir-maps')
|
|
93
|
+
FileUtils.mkdir_p(dir)
|
|
94
|
+
path = File.join(dir, "#{time.strftime('%Y%m%d-%H%M%S')}.json")
|
|
95
|
+
map = { 'root' => File.join(sync_dir, SyncStore::CHATS_DIR), 'moves' => moves.map(&:to_h) }
|
|
96
|
+
atomic_write(path, JSON.pretty_generate(map))
|
|
97
|
+
path
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def keep_dir?(request, others, from) = from && request.unchanged? && !owned?(others, from)
|
|
103
|
+
|
|
104
|
+
def assign_dir(request, others, chat_id)
|
|
105
|
+
name = build_dir_name(chat_id, request.display_name)
|
|
106
|
+
name = fallback_dir_name(chat_id) if dir_claimed?(others, request.wanted_dir(name))
|
|
107
|
+
request.assign(name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def existing_dir_kind(others, from, to)
|
|
111
|
+
return :kept if from == to
|
|
112
|
+
return :release if owned?(others, from)
|
|
113
|
+
|
|
114
|
+
from_path = chats_path(from)
|
|
115
|
+
return :retarget unless File.directory?(from_path)
|
|
116
|
+
|
|
117
|
+
target_taken?(from_path, chats_path(to)) ? :repoint : :move
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# On a case-insensitive volume a case-only rename finds the chat's own directory at the new path
|
|
121
|
+
def target_taken?(from_path, to_path) = File.exist?(to_path) && !File.identical?(from_path, to_path)
|
|
122
|
+
|
|
123
|
+
def rename_chat_dir(move)
|
|
124
|
+
new_path = chats_path(move.to)
|
|
125
|
+
FileUtils.mkdir_p(File.dirname(new_path))
|
|
126
|
+
File.rename(chats_path(move.from), new_path)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
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
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Teems
|
|
4
4
|
module Services
|
|
5
|
-
# File I/O helpers for SyncStore: atomic writes, backup, and JSON persistence
|
|
5
|
+
# File I/O helpers for SyncStore and MailSyncStore: atomic writes, backup, and JSON persistence
|
|
6
6
|
module SyncFileOps
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
+
# The temporary name carries the PID, so two processes never write the same one
|
|
9
10
|
def atomic_write(path, content)
|
|
10
|
-
tmp_path = "#{path}.tmp"
|
|
11
|
+
tmp_path = "#{path}.tmp.#{Process.pid}"
|
|
11
12
|
File.write(tmp_path, content)
|
|
12
13
|
File.rename(tmp_path, path)
|
|
13
14
|
end
|
|
@@ -20,7 +21,7 @@ module Teems
|
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def load_json_or_default(path, default)
|
|
23
|
-
return default unless File.
|
|
24
|
+
return default unless File.file?(path)
|
|
24
25
|
|
|
25
26
|
JSON.parse(File.read(path))
|
|
26
27
|
rescue JSON::ParserError
|
|
@@ -48,13 +49,11 @@ module Teems
|
|
|
48
49
|
|
|
49
50
|
# Chat state mutation operations for SyncStore
|
|
50
51
|
module SyncStateMutation
|
|
52
|
+
# A nil display name or chat type (sync --chat) keeps the stored one
|
|
51
53
|
def update_chat_state(state, chat_id, attrs:)
|
|
52
|
-
display_name, synced_at, count, chat_type =
|
|
53
|
-
attrs.values_at(:display_name, :last_synced_at, :message_count, :chat_type)
|
|
54
54
|
entry = (state['chats'] ||= {})[chat_id] ||= {}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'dir_name' => build_dir_name(chat_id, display_name))
|
|
55
|
+
dir_name = dir_name_for(entry, chat_id, attrs[:display_name])
|
|
56
|
+
entry.merge!(synced_fields(attrs, dir_name)).delete('resync_from')
|
|
58
57
|
state
|
|
59
58
|
end
|
|
60
59
|
|
|
@@ -68,6 +67,13 @@ module Teems
|
|
|
68
67
|
|
|
69
68
|
private
|
|
70
69
|
|
|
70
|
+
def synced_fields(attrs, dir_name)
|
|
71
|
+
display_name, synced_at, count, chat_type =
|
|
72
|
+
attrs.values_at(:display_name, :last_synced_at, :message_count, :chat_type)
|
|
73
|
+
{ 'last_synced_at' => synced_at.iso8601, 'message_count' => count, 'display_name' => display_name,
|
|
74
|
+
'chat_type' => chat_type, 'dir_name' => dir_name }.compact
|
|
75
|
+
end
|
|
76
|
+
|
|
71
77
|
def apply_unavailable(entry)
|
|
72
78
|
entry.merge!('unavailable' => true, 'unavailable_at' => Time.now.iso8601)
|
|
73
79
|
end
|
|
@@ -79,9 +85,11 @@ module Teems
|
|
|
79
85
|
def apply_display_info(entry, chat_id, display_name)
|
|
80
86
|
return unless display_name
|
|
81
87
|
|
|
82
|
-
entry.merge!('display_name' => display_name,
|
|
83
|
-
'dir_name' => build_dir_name(chat_id, display_name))
|
|
88
|
+
entry.merge!('display_name' => display_name, 'dir_name' => dir_name_for(entry, chat_id, display_name))
|
|
84
89
|
end
|
|
90
|
+
|
|
91
|
+
# ensure_chat_dir owns the directory name; this only fills it in for callers that skipped it
|
|
92
|
+
def dir_name_for(entry, chat_id, display_name) = entry['dir_name'] || build_dir_name(chat_id, display_name)
|
|
85
93
|
end
|
|
86
94
|
|
|
87
95
|
# Chat directory resolution for SyncStore
|
|
@@ -118,40 +126,17 @@ module Teems
|
|
|
118
126
|
end
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
# Directory rename helpers for SyncStore
|
|
122
|
-
module SyncRenameOps
|
|
123
|
-
private
|
|
124
|
-
|
|
125
|
-
def rename_entry_dir(entry, new_dir_name, chat_type)
|
|
126
|
-
old_name = entry['dir_name']
|
|
127
|
-
old_type = entry['chat_type']
|
|
128
|
-
if old_name && (old_name != new_dir_name || old_type != chat_type)
|
|
129
|
-
move_chat_dir(chat_type_path(old_type, old_name),
|
|
130
|
-
chat_type_path(chat_type, new_dir_name))
|
|
131
|
-
end
|
|
132
|
-
entry.merge!('dir_name' => new_dir_name, 'chat_type' => chat_type)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def move_chat_dir(old_path, new_path)
|
|
136
|
-
return if old_path == new_path || !File.directory?(old_path) || File.exist?(new_path)
|
|
137
|
-
|
|
138
|
-
FileUtils.mkdir_p(File.dirname(new_path))
|
|
139
|
-
File.rename(old_path, new_path)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def chat_type_path(type, name) = File.join(sync_dir, SyncStore::CHATS_DIR, type_dir(type), name)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
129
|
# Manages local sync state and file storage for the sync command.
|
|
146
130
|
# Stores chat history as Markdown + JSON in XDG data directory.
|
|
147
131
|
class SyncStore
|
|
148
132
|
include SyncDirNaming
|
|
133
|
+
include SyncDirOwnership
|
|
134
|
+
include SyncDirPlanning
|
|
149
135
|
include SyncFileOps
|
|
150
136
|
include SyncStateQuery
|
|
151
137
|
include SyncStateMutation
|
|
152
138
|
include SyncChatDir
|
|
153
139
|
include SyncChatWrite
|
|
154
|
-
include SyncRenameOps
|
|
155
140
|
|
|
156
141
|
SYNC_DIR = 'sync'
|
|
157
142
|
STATE_FILE = 'sync_state.json'
|
|
@@ -180,14 +165,6 @@ module Teems
|
|
|
180
165
|
FileUtils.mkdir_p(sync_dir)
|
|
181
166
|
atomic_write(File.join(sync_dir, STATE_FILE), JSON.pretty_generate(state))
|
|
182
167
|
end
|
|
183
|
-
|
|
184
|
-
def ensure_dir_name(state, chat_info:)
|
|
185
|
-
chat_id, display_name, chat_type = chat_info.values_at(:chat_id, :display_name, :chat_type)
|
|
186
|
-
new_dir_name = build_dir_name(chat_id, display_name)
|
|
187
|
-
entry = (state['chats'] ||= {})[chat_id] ||= {}
|
|
188
|
-
rename_entry_dir(entry, new_dir_name, chat_type)
|
|
189
|
-
new_dir_name
|
|
190
|
-
end
|
|
191
168
|
end
|
|
192
169
|
end
|
|
193
170
|
end
|
|
@@ -298,6 +298,10 @@ module Teems
|
|
|
298
298
|
try_headless_extract || try_safari_oauth || safari_extract
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
+
# Only the headless helper: renews tokens from the cached sign-in session without
|
|
302
|
+
# opening Safari or any window, or returns nil when there is no such session
|
|
303
|
+
def extract_silently = try_headless_extract
|
|
304
|
+
|
|
301
305
|
def manual_instructions = MANUAL_TOKEN_INSTRUCTIONS
|
|
302
306
|
|
|
303
307
|
private
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Support
|
|
5
|
+
# Removes C0 and C1 control characters, other than tab and newline, from text a sender
|
|
6
|
+
# controls. Printed to a terminal they are escape sequences: ESC ]52 sets the clipboard
|
|
7
|
+
# on terminals that allow OSC 52, and others move the cursor or rewrite the screen.
|
|
8
|
+
module ControlChars
|
|
9
|
+
CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def strip(text)
|
|
14
|
+
string = text.to_s
|
|
15
|
+
string.match?(CONTROLS) ? string.gsub(CONTROLS, '') : string
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Support
|
|
5
|
+
# Header fields with parameters, e.g. `text/plain; charset="utf-8"` (RFC 2045), including
|
|
6
|
+
# RFC 2231 (filename*=utf-8''...) and RFC 2047 encoded words (=?utf-8?B?...?=)
|
|
7
|
+
module MimeHeader
|
|
8
|
+
ENCODED_WORD = /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/
|
|
9
|
+
# Parameters split on ";" outside quotes
|
|
10
|
+
PARAMS = /(?:"(?:\\.|[^"\\])*"|[^;"])+/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# [lowercased value, { lowercased name => value }]
|
|
15
|
+
def field(text)
|
|
16
|
+
value, *params = text.to_s.scan(PARAMS)
|
|
17
|
+
[value.to_s.strip.downcase, params.to_h { |param| parameter(param) }]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parameter(text)
|
|
21
|
+
name, value = text.split('=', 2)
|
|
22
|
+
value = value.to_s.strip
|
|
23
|
+
value = value[1...-1].gsub(/\\(.)/, '\1') if value.start_with?('"') && value.end_with?('"')
|
|
24
|
+
[name.strip.downcase, value]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A parameter's text, preferring the RFC 2231 form (name*) when present
|
|
28
|
+
def decoded(params, name)
|
|
29
|
+
extended = params["#{name}*"]
|
|
30
|
+
return percent_decoded(extended) if extended
|
|
31
|
+
|
|
32
|
+
plain = params[name]
|
|
33
|
+
words_decoded(plain) if plain
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def percent_decoded(text)
|
|
37
|
+
charset, _language, encoded = text.split("'", 3)
|
|
38
|
+
return text unless encoded
|
|
39
|
+
|
|
40
|
+
transcode(encoded.gsub(/%(\h\h)/) { ::Regexp.last_match(1).hex.chr }, charset)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def words_decoded(text)
|
|
44
|
+
text.gsub(ENCODED_WORD) do
|
|
45
|
+
charset, kind, encoded = ::Regexp.last_match.captures
|
|
46
|
+
bytes = kind.casecmp?('B') ? encoded.unpack1('m') : encoded.tr('_', ' ').unpack1('M')
|
|
47
|
+
transcode(bytes, charset)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Bytes in charset as UTF-8; unknown charsets are read as UTF-8, and bad bytes become "?"
|
|
52
|
+
def transcode(bytes, charset)
|
|
53
|
+
text = bytes.dup
|
|
54
|
+
encoding = Encoding.find(charset.to_s.strip.empty? ? 'UTF-8' : charset)
|
|
55
|
+
text.force_encoding(encoding).encode('UTF-8', invalid: :replace, undef: :replace, replace: '?')
|
|
56
|
+
rescue ArgumentError
|
|
57
|
+
text.force_encoding('UTF-8').scrub('?')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Support
|
|
5
|
+
# One MIME entity (RFC 2045/2046): headers, a decoded body, and parts when multipart.
|
|
6
|
+
# Enough to read a decrypted S/MIME message: text and HTML bodies, attachment names,
|
|
7
|
+
# nested multiparts.
|
|
8
|
+
class MimePart
|
|
9
|
+
SIGNED_MULTIPART = 'multipart/signed'
|
|
10
|
+
PKCS7_TYPES = %w[application/pkcs7-mime application/x-pkcs7-mime].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :headers, :body
|
|
13
|
+
|
|
14
|
+
def self.parse(raw)
|
|
15
|
+
raw = raw.b
|
|
16
|
+
return new({}, raw.sub(/\A\r?\n/, '')) if raw.match?(/\A\r?\n/)
|
|
17
|
+
|
|
18
|
+
head, body = raw.split(/\r?\n\r?\n/, 2)
|
|
19
|
+
new(header_fields(head.to_s), body.to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Unfolded, names lowercased; the first of a repeated header wins
|
|
23
|
+
def self.header_fields(head)
|
|
24
|
+
head.gsub(/\r?\n[ \t]+/, ' ').split(/\r?\n/).each_with_object({}) do |line, fields|
|
|
25
|
+
name, value = line.split(':', 2)
|
|
26
|
+
fields[name.strip.downcase] ||= value.strip if value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(headers, body)
|
|
31
|
+
@headers = headers
|
|
32
|
+
@body = body
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def content_type = type_field.first.then { |type| type.empty? ? 'text/plain' : type }
|
|
36
|
+
def multipart? = content_type.start_with?('multipart/')
|
|
37
|
+
def text? = %w[text/plain text/html].include?(content_type)
|
|
38
|
+
|
|
39
|
+
def parts
|
|
40
|
+
boundary = type_field.last['boundary']
|
|
41
|
+
return [] unless multipart? && boundary
|
|
42
|
+
|
|
43
|
+
sections = body.split(/(?:\A|\r?\n)--#{Regexp.escape(boundary.b)}/).drop(1)
|
|
44
|
+
sections.take_while { |section| !section.start_with?('--') }.map do |section|
|
|
45
|
+
MimePart.parse(section.sub(/\A[ \t]*\r?\n/, ''))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The parts with no parts of their own, in order (this part when it is not multipart)
|
|
50
|
+
def leaves = multipart? ? parts.flat_map(&:leaves) : [self]
|
|
51
|
+
|
|
52
|
+
# [the content inside any signature layers, whether there were any]
|
|
53
|
+
def unsigned
|
|
54
|
+
inner = signed_content
|
|
55
|
+
inner ? [inner.unsigned.first, true] : [self, false]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The part a signature wraps (multipart/signed keeps its first part, opaque signed
|
|
59
|
+
# PKCS #7 gives up the content inside it), or nil when this part is not signed
|
|
60
|
+
def signed_content
|
|
61
|
+
type = content_type
|
|
62
|
+
return parts.first if type == SIGNED_MULTIPART
|
|
63
|
+
|
|
64
|
+
MimePart.parse(SignedData.content(decoded)) if PKCS7_TYPES.include?(type)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The body with its transfer encoding undone, as bytes
|
|
68
|
+
def decoded
|
|
69
|
+
case headers['content-transfer-encoding'].to_s.strip.downcase
|
|
70
|
+
when 'base64' then body.unpack1('m')
|
|
71
|
+
when 'quoted-printable' then body.gsub("\r\n", "\n").unpack1('M')
|
|
72
|
+
else body
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The decoded body as UTF-8 text, from its charset
|
|
77
|
+
def text = MimeHeader.transcode(decoded, type_field.last['charset'])
|
|
78
|
+
|
|
79
|
+
def filename
|
|
80
|
+
params = MimeHeader.field(headers['content-disposition']).last
|
|
81
|
+
MimeHeader.decoded(params, 'filename') || MimeHeader.decoded(type_field.last, 'name')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Something a person attached, as opposed to the message body or an image shown inline
|
|
85
|
+
def attachment?
|
|
86
|
+
disposition = MimeHeader.field(headers['content-disposition']).first
|
|
87
|
+
return true if disposition == 'attachment'
|
|
88
|
+
|
|
89
|
+
disposition.empty? && !text? && !headers.key?('content-id') && !filename.to_s.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def type_field = @type_field ||= MimeHeader.field(headers['content-type'])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|