teems 0.3.3 → 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 +67 -0
- data/README.md +77 -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/sync.rb +15 -8
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/formatters/format_utils.rb +23 -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/output.rb +45 -8
- 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/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/runner.rb +6 -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/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_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/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +63 -2
- metadata +48 -1
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Teems
|
|
4
4
|
module Services
|
|
5
|
-
# Directory naming helpers for SyncStore
|
|
5
|
+
# Directory naming helpers for SyncStore.
|
|
6
|
+
#
|
|
7
|
+
# A chat directory name ends with the chat's full sanitized ID, so two chats can't share one.
|
|
8
|
+
# Older teems versions keyed generic labels ("Group Chat") by a 20-character ID prefix and
|
|
9
|
+
# named chats by their title alone, which merged unrelated chats. Those names are kept for
|
|
10
|
+
# chats that still own their directory (see SyncDirPlanning); SyncDirOwnership handles
|
|
11
|
+
# directories that ended up shared.
|
|
6
12
|
module SyncDirNaming
|
|
7
|
-
GENERIC_LABELS = ['Group Chat', '1:1 Chat', 'Meeting Chat', 'Channel', 'Space'].freeze
|
|
8
13
|
MAX_DIR_NAME_LENGTH = 100
|
|
14
|
+
# Most filesystems limit one path component to 255 bytes
|
|
15
|
+
MAX_DIR_NAME_BYTES = 255
|
|
9
16
|
TYPE_DIRS = {
|
|
10
17
|
'oneOnOne' => 'dms', 'group' => 'groups', 'meeting' => 'meetings',
|
|
11
18
|
'channel' => 'channels', 'space' => 'spaces'
|
|
@@ -21,21 +28,20 @@ module Teems
|
|
|
21
28
|
id.gsub(/[:@]/, '_')
|
|
22
29
|
end
|
|
23
30
|
|
|
24
|
-
def sanitize_display_name(name)
|
|
31
|
+
def sanitize_display_name(name, max_bytes: MAX_DIR_NAME_BYTES)
|
|
25
32
|
return nil if name.to_s.strip.empty?
|
|
26
33
|
|
|
27
|
-
sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')
|
|
28
|
-
sanitized = sanitized
|
|
34
|
+
sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')[0, MAX_DIR_NAME_LENGTH]
|
|
35
|
+
sanitized = sanitized.byteslice(0, max_bytes).scrub('').gsub(/[\s.]+\z/, '')
|
|
29
36
|
sanitized.empty? ? nil : sanitized
|
|
30
37
|
end
|
|
31
38
|
|
|
39
|
+
# "<display name> (<full sanitized chat ID>)", or just the sanitized ID when there is no name
|
|
32
40
|
def build_dir_name(chat_id, display_name)
|
|
33
|
-
sanitized = sanitize_display_name(display_name)
|
|
34
41
|
safe_id = sanitize_id(chat_id)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"#{sanitized} (#{safe_id[0, 20]})"
|
|
42
|
+
label_bytes = [MAX_DIR_NAME_BYTES - safe_id.bytesize - ' ()'.bytesize, 0].max
|
|
43
|
+
label = sanitize_display_name(display_name, max_bytes: label_bytes)
|
|
44
|
+
label ? "#{label} (#{safe_id})" : safe_id
|
|
39
45
|
end
|
|
40
46
|
end
|
|
41
47
|
end
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Teems
|
|
6
|
+
module Support
|
|
7
|
+
# The content inside an opaque S/MIME signature (PKCS #7 signed-data). The signature is not
|
|
8
|
+
# checked: this only unwraps it so the message can be read.
|
|
9
|
+
module SignedData
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def content(der)
|
|
13
|
+
envelope = OpenSSL::PKCS7.new(der)
|
|
14
|
+
raise Error, 'This message is encrypted again inside; teems opens one layer' unless envelope.type == :signed
|
|
15
|
+
|
|
16
|
+
envelope.verify([], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOSIGS)
|
|
17
|
+
envelope.data.to_s
|
|
18
|
+
rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError => e
|
|
19
|
+
raise Error, "Could not read the signed content: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -3,17 +3,25 @@
|
|
|
3
3
|
module Teems
|
|
4
4
|
module Support
|
|
5
5
|
# Shared time-input parsing for commands that accept schedule inputs
|
|
6
|
-
# like "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
|
|
6
|
+
# like "now", "HH:MM", "today HH:MM", "tomorrow HH:MM", and "YYYY-MM-DD HH:MM".
|
|
7
7
|
module TimeParsing
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def parse_time_input(raw)
|
|
11
|
+
return current_minute if raw.strip.casecmp?('now')
|
|
12
|
+
|
|
11
13
|
date, time_str = split_time_input(raw)
|
|
12
14
|
return unless time_str
|
|
13
15
|
|
|
14
16
|
date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
|
|
15
17
|
end
|
|
16
18
|
|
|
19
|
+
# The start of the current minute, as "HH:MM" would give it
|
|
20
|
+
def current_minute
|
|
21
|
+
now = Time.now
|
|
22
|
+
Time.new(now.year, now.month, now.day, now.hour, now.min, 0)
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
def split_time_input(raw)
|
|
18
26
|
base = Date.today
|
|
19
27
|
split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
|
data/lib/teems/version.rb
CHANGED