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.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/README.md +84 -0
  4. data/bin/teems +8 -1
  5. data/lib/teems/api/calendar.rb +45 -14
  6. data/lib/teems/api/client.rb +19 -4
  7. data/lib/teems/api/mail.rb +132 -0
  8. data/lib/teems/api/mail_actions.rb +22 -0
  9. data/lib/teems/api/mail_rules.rb +24 -0
  10. data/lib/teems/cli.rb +2 -0
  11. data/lib/teems/commands/base.rb +31 -7
  12. data/lib/teems/commands/cal.rb +109 -28
  13. data/lib/teems/commands/cal_ics.rb +47 -0
  14. data/lib/teems/commands/confirmation.rb +19 -0
  15. data/lib/teems/commands/help.rb +4 -0
  16. data/lib/teems/commands/mail.rb +207 -0
  17. data/lib/teems/commands/mail_decrypting.rb +46 -0
  18. data/lib/teems/commands/mail_help.rb +85 -0
  19. data/lib/teems/commands/mail_rules.rb +201 -0
  20. data/lib/teems/commands/mail_sync.rb +92 -0
  21. data/lib/teems/commands/mail_thread.rb +87 -0
  22. data/lib/teems/commands/mail_triage.rb +241 -0
  23. data/lib/teems/commands/meet.rb +159 -0
  24. data/lib/teems/commands/meeting.rb +4 -3
  25. data/lib/teems/commands/messages.rb +51 -8
  26. data/lib/teems/commands/sync.rb +45 -11
  27. data/lib/teems/commands/sync_folders.rb +118 -0
  28. data/lib/teems/commands/transcripts.rb +205 -137
  29. data/lib/teems/formatters/format_utils.rb +26 -2
  30. data/lib/teems/formatters/html_entities.rb +49 -0
  31. data/lib/teems/formatters/html_text.rb +181 -0
  32. data/lib/teems/formatters/ics_format.rb +112 -0
  33. data/lib/teems/formatters/link_cleaner.rb +91 -0
  34. data/lib/teems/formatters/link_collector.rb +70 -0
  35. data/lib/teems/formatters/mail_formatter.rb +58 -0
  36. data/lib/teems/formatters/mail_markdown.rb +70 -0
  37. data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
  38. data/lib/teems/formatters/mail_rule_text.rb +67 -0
  39. data/lib/teems/formatters/markdown_formatter.rb +10 -1
  40. data/lib/teems/formatters/message_formatter.rb +7 -2
  41. data/lib/teems/formatters/output.rb +45 -8
  42. data/lib/teems/formatters/transcript_markdown.rb +16 -12
  43. data/lib/teems/models/chat.rb +19 -5
  44. data/lib/teems/models/event.rb +1 -23
  45. data/lib/teems/models/event_display.rb +40 -0
  46. data/lib/teems/models/ics_event.rb +60 -0
  47. data/lib/teems/models/inline_image.rb +83 -0
  48. data/lib/teems/models/mail_conversation.rb +62 -0
  49. data/lib/teems/models/mail_encryption.rb +41 -0
  50. data/lib/teems/models/mail_folder.rb +44 -0
  51. data/lib/teems/models/mail_message.rb +152 -0
  52. data/lib/teems/models/mail_rule.rb +38 -0
  53. data/lib/teems/models/message.rb +7 -4
  54. data/lib/teems/models/parsing.rb +5 -0
  55. data/lib/teems/runner.rb +11 -0
  56. data/lib/teems/services/api_client.rb +23 -11
  57. data/lib/teems/services/browser.rb +17 -0
  58. data/lib/teems/services/clipboard.rb +17 -0
  59. data/lib/teems/services/inline_image_downloader.rb +115 -0
  60. data/lib/teems/services/mail_folders.rb +76 -0
  61. data/lib/teems/services/mail_id_cache.rb +51 -0
  62. data/lib/teems/services/mail_legacy_files.rb +96 -0
  63. data/lib/teems/services/mail_sync_attachments.rb +103 -0
  64. data/lib/teems/services/mail_sync_engine.rb +98 -0
  65. data/lib/teems/services/mail_sync_folder.rb +142 -0
  66. data/lib/teems/services/mail_sync_store.rb +116 -0
  67. data/lib/teems/services/smime_content.rb +36 -0
  68. data/lib/teems/services/smime_decryptor.rb +36 -0
  69. data/lib/teems/services/sync_dir_naming.rb +16 -10
  70. data/lib/teems/services/sync_dir_ownership.rb +111 -0
  71. data/lib/teems/services/sync_dir_planning.rb +130 -0
  72. data/lib/teems/services/sync_engine.rb +13 -7
  73. data/lib/teems/services/sync_images.rb +55 -0
  74. data/lib/teems/services/sync_store.rb +20 -43
  75. data/lib/teems/services/token_extractor.rb +4 -0
  76. data/lib/teems/support/control_chars.rb +19 -0
  77. data/lib/teems/support/mime_header.rb +61 -0
  78. data/lib/teems/support/mime_part.rb +97 -0
  79. data/lib/teems/support/signed_data.rb +23 -0
  80. data/lib/teems/support/subprocess.rb +2 -0
  81. data/lib/teems/support/time_parsing.rb +9 -1
  82. data/lib/teems/version.rb +1 -1
  83. data/lib/teems.rb +67 -2
  84. metadata +51 -1
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Teems
6
+ module Commands
7
+ MEET_HELP = <<~HELP
8
+ teems meet - Start a Teams meeting and print its join link
9
+
10
+ USAGE:
11
+ teems meet [minutes] [options]
12
+
13
+ Puts a Teams meeting on your calendar, starting now for 30 minutes unless told
14
+ otherwise, prints its join link (alone on stdout, so it pipes) and opens it here
15
+ to join. With nobody invited, nothing is sent.
16
+
17
+ Joining here matters: people from organizations yours does not trust wait in the
18
+ lobby until a member of yours joins, even with "Everyone" set to bypass the lobby
19
+ in Meeting options, and even when invited. Once you are in you can admit them;
20
+ the meeting usually carries on after you leave.
21
+
22
+ OPTIONS:
23
+ --at TIME Start: now (default), HH:MM, "today HH:MM",
24
+ "tomorrow HH:MM", or "YYYY-MM-DD HH:MM"
25
+ --title TEXT Event title (default: Meet now)
26
+ --with EMAILS Comma-separated people to invite. Exchange emails
27
+ each an invitation, so this asks first
28
+ -y, --yes Invite without asking (needed without a terminal)
29
+ --copy Also copy the link to the clipboard
30
+ --no-open Do not open the link here (just print it)
31
+ -q, --quiet Just the link
32
+ --json Event ID, title, start, end and link as JSON
33
+
34
+ EXAMPLES:
35
+ teems meet # Now, 30 minutes, and join here
36
+ teems meet 60 --title "Pairing" # Now, an hour
37
+ teems meet --copy # Also copy the link
38
+ teems meet --no-open | pbcopy # Just the link, for someone else
39
+ teems meet --at 14:30 --with alice@example.com
40
+ HELP
41
+
42
+ # `teems meet`: a calendar event with a Teams meeting, starting now by default, and
43
+ # its join link. Calendars.ReadWrite is enough for that, where creating a bare online
44
+ # meeting would need OnlineMeetings.ReadWrite.
45
+ class Meet < Base
46
+ include Support::Timezone
47
+ include Support::TimeParsing
48
+ include Confirmation
49
+
50
+ DEFAULT_MINUTES = 30
51
+ MAX_MINUTES = 24 * 60
52
+ VALUE_OPTIONS = { '--at' => :at, '--title' => :title, '--with' => :with }.freeze
53
+ # --open is the default; it is still accepted, so scripts that pass it keep working
54
+ FLAGS = { '--copy' => :copy, '--open' => :open, '--no-open' => :no_open, '--yes' => :yes, '-y' => :yes }.freeze
55
+
56
+ def initialize(args, runner:)
57
+ @options = {}
58
+ super
59
+ end
60
+
61
+ def execute
62
+ validation = validate_options
63
+ return validation if validation
64
+
65
+ auth = require_auth
66
+ return auth if auth
67
+
68
+ with_token_refresh { create_meeting }
69
+ rescue ApiError => e
70
+ api_error_result('Could not create the meeting', e)
71
+ rescue Teems::Error => e
72
+ error(e.message)
73
+ end
74
+
75
+ protected
76
+
77
+ def handle_option(arg, pending)
78
+ return @options[FLAGS[arg]] = true if FLAGS.key?(arg)
79
+ return super unless VALUE_OPTIONS.key?(arg)
80
+
81
+ @options[VALUE_OPTIONS[arg]] = pending.shift
82
+ end
83
+
84
+ def help_text = MEET_HELP
85
+
86
+ private
87
+
88
+ def create_meeting
89
+ start = start_time
90
+ finish = start + (minutes * 60)
91
+ return error('Cancelled; no meeting created') unless invite_confirmed?
92
+
93
+ event = runner.calendar_api.create_event(event_body(start, finish))
94
+ link = event.online_meeting_url or raise Error, 'Graph created the event but gave no Teams link'
95
+ report(event, link, start..finish)
96
+ end
97
+
98
+ def minutes
99
+ given = positional_args.first
100
+ return DEFAULT_MINUTES unless given
101
+
102
+ count = Integer(given, 10, exception: false)
103
+ raise Error, "Minutes must be a whole number from 1 to #{MAX_MINUTES}" unless count&.between?(1, MAX_MINUTES)
104
+
105
+ count
106
+ end
107
+
108
+ def start_time
109
+ given = @options[:at] || 'now'
110
+ parse_time_input(given) or raise Error, "Unknown time: #{given} (use now, HH:MM, \"tomorrow HH:MM\", ...)"
111
+ end
112
+
113
+ def invitees = @options[:with].to_s.split(',').map(&:strip).reject(&:empty?)
114
+
115
+ # Inviting people sends them mail, so it needs a yes (at the prompt, or --yes)
116
+ def invite_confirmed?
117
+ people = invitees
118
+ return true if people.empty? || @options[:yes]
119
+
120
+ confirm?("Invite #{people.join(', ')} (Exchange emails each an invitation)",
121
+ refusal: 'Inviting people sends them email; pass --yes to go ahead')
122
+ end
123
+
124
+ def event_body(start, finish)
125
+ zone = detect_timezone
126
+ { subject: @options[:title] || 'Meet now', isOnlineMeeting: true, onlineMeetingProvider: 'teamsForBusiness',
127
+ start: { dateTime: graph_time(start), timeZone: zone }, end: { dateTime: graph_time(finish), timeZone: zone },
128
+ attendees: invitees.map { |address| { emailAddress: { address: address }, type: 'required' } },
129
+ transactionId: SecureRandom.uuid }
130
+ end
131
+
132
+ # On stderr, beside the link on stdout; -q leaves just the link
133
+ def note(message) = @options[:quiet] || output.note(message)
134
+
135
+ def graph_time(time) = time.strftime('%Y-%m-%dT%H:%M:%S')
136
+
137
+ def report(event, link, times)
138
+ return json_report(event, link, times) if @options[:json]
139
+
140
+ output.puts(link)
141
+ note("#{event.subject}, #{times.begin.strftime('%-I:%M')}-#{times.end.strftime('%-I:%M %p')}")
142
+ hand_off(link)
143
+ 0
144
+ end
145
+
146
+ def json_report(event, link, times)
147
+ hand_off(link)
148
+ output_json(id: event.id, title: event.subject, start: times.begin.iso8601, end: times.end.iso8601,
149
+ join_url: link)
150
+ 0
151
+ end
152
+
153
+ def hand_off(link)
154
+ runner.clipboard.copy(link) && note('Copied the link') if @options[:copy]
155
+ runner.browser.open(link) && note('Opened it to join') unless @options[:no_open]
156
+ end
157
+ end
158
+ end
159
+ end
@@ -613,10 +613,11 @@ module Teems
613
613
  private
614
614
 
615
615
  def output_meeting_json(target, classified)
616
+ call_events, recordings, transcripts = classified.values_at(:call_events, :recordings, :transcripts)
616
617
  output_json(thread_id: target[:thread_id],
617
- call_events: classified[:call_events].map { |event| json_call_event(event) },
618
- recordings: classified[:recordings].map { |rec| rec.slice(:time, :url, :call_id) },
619
- transcripts: classified[:transcripts].map { |item| item.slice(:time) })
618
+ call_events: call_events.map { |event| json_call_event(event) },
619
+ recordings: recordings.map { |rec| rec.slice(:time, :url, :call_id) },
620
+ transcripts: transcripts.map { |item| item.slice(:time) })
620
621
  0
621
622
  end
622
623
 
@@ -14,7 +14,7 @@ module Teems
14
14
  OPTIONS:
15
15
  -t, --team ID Team ID (required for channel messages)
16
16
  -n, --limit N Number of messages (default: 20)
17
- --download Download file attachments
17
+ --download Download file attachments and inline images
18
18
  -o, --output-dir Directory for downloads (default: ~/.local/share/teems/downloads)
19
19
  -v, --verbose Show debug output
20
20
  -q, --quiet Suppress output
@@ -23,10 +23,13 @@ module Teems
23
23
  EXAMPLES:
24
24
  teems messages 19:abc123@thread.v2 # Read chat messages
25
25
  teems messages 19:abc123@thread.v2 -n 50 # Show 50 messages
26
+ teems messages 19:abc123@thread.v2 --download # Save attachments + pasted images
26
27
  teems messages "https://teams.microsoft.com/l/message/19:abc@thread.v2/123?context=..."
27
28
  # Show the linked message and its thread replies
28
29
  HELP
29
30
 
31
+ IMAGE_ICON = "\u{1F5BC}\u{FE0F}"
32
+
30
33
  # Display formatting for messages command
31
34
  module MessagesDisplay
32
35
  REACTION_EMOJI = {
@@ -45,6 +48,7 @@ module Teems
45
48
  puts format_message_header(message)
46
49
  puts " #{formatted_content(message)}"
47
50
  display_attachments(message)
51
+ display_images(message)
48
52
  display_reactions(message)
49
53
  puts
50
54
  end
@@ -66,6 +70,11 @@ module Teems
66
70
  puts " #{output.gray("\u{1F4CE} #{names.join(', ')}")}"
67
71
  end
68
72
 
73
+ def display_images(message)
74
+ summary = Formatters::FormatUtils.image_summary(message.images)
75
+ puts " #{output.gray("#{IMAGE_ICON} #{summary}")}" unless summary.empty?
76
+ end
77
+
69
78
  def display_reactions(message)
70
79
  reactions = message.reactions
71
80
  return unless reactions.any?
@@ -85,7 +94,7 @@ module Teems
85
94
  created_at: message.created_at&.iso8601,
86
95
  importance: message.importance, reactions: message.reactions,
87
96
  attachments: message.attachments, edited: message.edited,
88
- mentions: message.mentions }
97
+ mentions: message.mentions, images: message.images.map(&:as_json) }
89
98
  end
90
99
  end
91
100
 
@@ -95,14 +104,15 @@ module Teems
95
104
 
96
105
  def download_attachments(messages)
97
106
  attachments = find_downloadable(messages)
98
- return puts('No downloadable attachments found') if attachments.empty?
107
+ images = inline_images(messages)
108
+ return puts('No downloadable attachments found') if attachments.empty? && images.empty?
99
109
 
100
- execute_downloads(attachments)
110
+ execute_downloads(attachments, images)
101
111
  end
102
112
 
103
- def execute_downloads(attachments)
113
+ def execute_downloads(attachments, images)
104
114
  dir = prepare_output_dir
105
- count = attachments.sum { |att| download_one(att, dir) }
115
+ count = attachments.sum { |att| download_one(att, dir) } + images.sum { |entry| download_image(entry, dir) }
106
116
  puts "Downloaded #{count} file#{'s' if count != 1} to #{dir}" if count.positive?
107
117
  end
108
118
 
@@ -168,6 +178,29 @@ module Teems
168
178
  end
169
179
  end
170
180
 
181
+ # Download logic for images pasted inline into message bodies (Teams AMS objects)
182
+ module InlineImageDownload
183
+ private
184
+
185
+ # [message, image, 1-based position within the message] for every inline image
186
+ def inline_images(messages) = messages.flat_map { |msg| numbered_images(msg) }
187
+
188
+ def numbered_images(msg) = msg.images.map.with_index(1) { |image, number| [msg, image, number] }
189
+
190
+ def download_image(entry, dir)
191
+ msg, image, number = entry
192
+ print "#{IMAGE_ICON} Downloading #{image.label}..."
193
+ result = with_token_refresh { image_downloader.fetch(image) }
194
+ path = unique_path(dir, "image-#{msg.short_hash}-#{number}.#{result.extension}")
195
+ puts " done (#{Formatters::FormatUtils.format_bytes(File.binwrite(path, result.body))}) #{path}"
196
+ 1
197
+ rescue StandardError => e
198
+ handle_download_error(e)
199
+ end
200
+
201
+ def image_downloader = @image_downloader ||= runner.inline_image_downloader
202
+ end
203
+
171
204
  # Fetch and render a single message and its thread replies
172
205
  module ThreadFetch
173
206
  private
@@ -175,7 +208,7 @@ module Teems
175
208
  def fetch_thread(thread_id, message_id)
176
209
  api = runner.messages_api
177
210
  parent_data = with_token_refresh { api.message(thread_id: thread_id, message_id: message_id) }
178
- replies_data = with_token_refresh do
211
+ replies_data = fetch_replies do
179
212
  api.replies(thread_id: thread_id, message_id: message_id, limit: @options[:limit])
180
213
  end
181
214
  display_thread(parent_data, extract_messages_data(replies_data))
@@ -183,10 +216,19 @@ module Teems
183
216
  error("Failed to fetch message: #{e.message}")
184
217
  end
185
218
 
219
+ # Group and 1:1 chats have no reply threads; their replies endpoint returns 404
220
+ def fetch_replies(&)
221
+ with_token_refresh(&)
222
+ rescue ApiError => e
223
+ raise unless e.not_found?
224
+
225
+ {}
226
+ end
227
+
186
228
  def display_thread(parent_data, replies_data)
187
229
  parent = Models::Message.from_api(parent_data)
188
230
  replies = replies_data.map { |data| Models::Message.from_api(data) }.reject(&:system_message?).reverse
189
- return output_json(thread_to_hash(parent, replies)) if @options[:json]
231
+ return output_json(thread_to_hash(parent, replies)) || 0 if @options[:json]
190
232
 
191
233
  render_thread(parent, replies)
192
234
  download_attachments([parent, *replies]) if @options[:download]
@@ -212,6 +254,7 @@ module Teems
212
254
  class Messages < Base
213
255
  include MessagesDisplay
214
256
  include AttachmentDownload
257
+ include InlineImageDownload
215
258
  include ThreadFetch
216
259
 
217
260
  def initialize(args, runner:)
@@ -11,8 +11,11 @@ module Teems
11
11
  OPTIONS:
12
12
  --since DAYS Number of days of history to sync (default: 180)
13
13
  --chat CHAT_ID Sync only this chat
14
+ --images Also download inline (pasted) images into each chat's images/ dir
14
15
  --auth Authenticate via Safari before syncing
15
- --dry-run Show what would be synced without writing files
16
+ --dry-run Show what would be synced and every folder change, without writing anything
17
+ --migrate-dirs Also rename existing chat folders to "<name> (<chat ID>)" and write
18
+ a map of old to new paths (sync/dir-maps/)
16
19
  -v, --verbose Show debug output
17
20
  -q, --quiet Suppress output
18
21
 
@@ -20,11 +23,15 @@ module Teems
20
23
  teems sync # Sync 6 months of all chats
21
24
  teems sync --since 30 # Sync last 30 days
22
25
  teems sync --chat 19:abc@thread.v2 # Sync a single chat
26
+ teems sync --images # Include pasted screenshots
23
27
  teems sync --dry-run # Preview what would be synced
28
+ teems sync --dry-run --migrate-dirs # Preview renaming every folder
24
29
 
25
30
  OUTPUT:
26
31
  Files are stored in ~/.local/share/teems/sync/chats/
27
32
  Each chat gets: messages.md, messages.json, chat_metadata.json
33
+ (plus images/ with --images; messages.md links saved images, otherwise [image: ...])
34
+ New chats get a "<name> (<chat ID>)" folder; existing folders keep their names.
28
35
  HELP
29
36
 
30
37
  # Handles syncing individual chats: fetch, merge, retry on 404
@@ -59,10 +66,7 @@ module Teems
59
66
  end
60
67
 
61
68
  def sync_single_chat(chat)
62
- @sync_store.ensure_dir_name(
63
- @state, chat_info: { chat_id: chat.id, display_name: chat.display_name,
64
- chat_type: chat.chat_type }
65
- )
69
+ report_dir_move(@sync_store.ensure_chat_dir(@state, chat_info: dir_info(chat)))
66
70
  new_messages = fetch_new_messages(chat)
67
71
  debug(" Fetched #{new_messages.length} new message(s)")
68
72
  process_fetched_messages(chat, new_messages)
@@ -70,20 +74,24 @@ module Teems
70
74
 
71
75
  def process_fetched_messages(chat, new_messages)
72
76
  chat_id = chat.id
73
- return skip_unchanged(chat_id) if new_messages.empty? && @sync_store.last_synced_time(@state, chat_id)
77
+ # --images rewrites unchanged chats too, so images in already-synced messages are backfilled
78
+ unchanged = new_messages.empty? && !@options[:images]
79
+ return skip_unchanged(chat_id) if unchanged && @sync_store.last_synced_time(@state, chat_id)
74
80
 
75
81
  merge_and_update(chat, new_messages)
76
82
  end
77
83
 
78
84
  def fetch_new_messages(chat)
79
85
  chat_id = chat.id
80
- start_time = @sync_store.last_synced_time(@state, chat_id) || since_time
86
+ start_time = @sync_store.last_synced_time(@state, chat_id) || resync_start(@state, chat_id)
81
87
  with_token_refresh { sync_engine.fetch_all_messages(chat_id, start_time) }
82
88
  end
83
89
 
84
90
  def merge_and_update(chat, new_messages)
85
91
  existing_raw = @sync_store.read_messages_json(chat.id, state: @state)
86
- all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages)
92
+ all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages) do |chat_dir, messages|
93
+ save_chat_images(chat_dir, messages)
94
+ end
87
95
  update_chat_state(chat, all_messages)
88
96
  end
89
97
 
@@ -138,6 +146,23 @@ module Teems
138
146
  end
139
147
  end
140
148
 
149
+ # Opt-in (--images) download of inline images into each synced chat directory
150
+ module SyncImageHandler
151
+ private
152
+
153
+ def save_chat_images(chat_dir, messages)
154
+ return unless @options[:images]
155
+
156
+ saved = sync_images.save(chat_dir, messages)
157
+ debug(" Saved #{saved} image(s)")
158
+ @stats[:images] += saved
159
+ end
160
+
161
+ def sync_images
162
+ @sync_images ||= Services::SyncImages.new(downloader: runner.inline_image_downloader, output: output)
163
+ end
164
+ end
165
+
141
166
  # Chat list fetching, dry-run display, and summary reporting
142
167
  module SyncDisplay
143
168
  private
@@ -174,12 +199,13 @@ module Teems
174
199
  def show_dry_run(chats)
175
200
  syncable = chats.reject { |chat| skip_reason(chat['id']) }
176
201
  display_dry_run_list(chats.length - syncable.length, syncable)
202
+ show_dir_plan(syncable)
203
+ 0
177
204
  end
178
205
 
179
206
  def display_dry_run_list(skipped_count, syncable)
180
207
  display_dry_run_header(skipped_count, syncable.length)
181
208
  syncable.each { |chat| format_dry_run_chat(chat) }
182
- 0
183
209
  end
184
210
 
185
211
  def display_dry_run_header(skipped, syncable_count)
@@ -204,6 +230,7 @@ module Teems
204
230
  success('Sync complete!')
205
231
  show_summary_stats
206
232
  info(" Output: #{@sync_store.sync_dir}")
233
+ show_folder_summary
207
234
  end
208
235
 
209
236
  def show_summary_stats
@@ -211,6 +238,7 @@ module Teems
211
238
  skipped = @stats[:skipped]
212
239
  info(" Chats skipped (no new messages): #{skipped}") if skipped.positive?
213
240
  info(" Total messages: #{@stats[:messages_total]}")
241
+ info(" Images downloaded: #{@stats[:images]}") if @options[:images]
214
242
  display_error_count
215
243
  end
216
244
 
@@ -287,8 +315,10 @@ module Teems
287
315
  # Sync chat history locally as Markdown + JSON files
288
316
  class Sync < Base
289
317
  include SyncChatHandler
318
+ include SyncImageHandler
290
319
  include SyncDisplay
291
320
  include SyncAuth
321
+ include SyncFolders
292
322
 
293
323
  DEFAULT_SINCE_DAYS = 180
294
324
  SKIP_PREFIXES = %w[48:].freeze
@@ -318,6 +348,8 @@ module Teems
318
348
  '--since' => ->(opts, args) { opts[:since_days] = args.shift.to_i },
319
349
  '--chat' => ->(opts, args) { opts[:chat_id] = args.shift },
320
350
  '--dry-run' => ->(opts, _args) { opts[:dry_run] = true },
351
+ '--images' => ->(opts, _args) { opts[:images] = true },
352
+ '--migrate-dirs' => ->(opts, _args) { opts[:migrate_dirs] = true },
321
353
  '--auth' => ->(opts, _args) { opts[:auth] = true }
322
354
  }.freeze
323
355
 
@@ -342,6 +374,7 @@ module Teems
342
374
  end
343
375
 
344
376
  def sync_all_chats(chats)
377
+ detach_shared_dirs
345
378
  chats.each_with_index { |chat_data, index| sync_or_skip_chat(chat_data, "[#{index + 1}/#{chats.length}]") }
346
379
  save_state_safely
347
380
  show_summary
@@ -353,7 +386,7 @@ module Teems
353
386
  def init_sync_state
354
387
  @sync_store = Services::SyncStore.new
355
388
  @state = @sync_store.load_state
356
- @stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0 }
389
+ @stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0, images: 0 }
357
390
  setup_api_logging
358
391
  end
359
392
 
@@ -377,7 +410,8 @@ module Teems
377
410
  error("Warning: Failed to save sync state: #{e.message}")
378
411
  end
379
412
 
380
- def since_time = Time.now - ((@options[:since_days] || DEFAULT_SINCE_DAYS) * 86_400)
413
+ def since_days = @options[:since_days] || DEFAULT_SINCE_DAYS
414
+ def since_time = Time.now - (since_days * 86_400)
381
415
 
382
416
  def setup_api_logging
383
417
  out = output
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Commands
5
+ # Chat folder changes made by sync: detaching shared folders, moving folders, listing old
6
+ # shared folders still on disk, and previewing all of it for --dry-run
7
+ module SyncFolders
8
+ # How each kind of folder change is described; :split is described by split_note, and :new
9
+ # and :kept aren't reported
10
+ MOVE_NOTES = {
11
+ move: '',
12
+ retarget: ' (the old folder is already gone)',
13
+ repoint: ' (the new folder already exists: switching to it, the old folder is left in place)',
14
+ release: " (the old folder also holds another chat's messages and is left in place)"
15
+ }.freeze
16
+
17
+ private
18
+
19
+ def dir_info(chat) = { **chat.sync_identity, migrate: @options[:migrate_dirs] }
20
+
21
+ # sync --chat X only detaches X, so the other chats keep their cursors until they sync
22
+ def detach_shared_dirs
23
+ detached = @sync_store.detach_shared_dirs(@state, only: @options[:chat_id])
24
+ @detached_dirs = detached.keys
25
+ warn_shared_dirs(detached)
26
+ end
27
+
28
+ def warn_shared_dirs(shared)
29
+ return if shared.empty?
30
+
31
+ warn([shared_dirs_intro(shared), *dir_lines(shared), shared_dirs_plan].join("\n"))
32
+ end
33
+
34
+ def shared_dirs_intro(shared)
35
+ "#{shared.length} folder(s) in #{File.join(@sync_store.sync_dir, 'chats')} mix messages from " \
36
+ 'several chats (an older teems gave those chats the same folder name):'
37
+ end
38
+
39
+ def shared_dirs_plan
40
+ 'These folders are left as they are. Each affected chat now syncs to its own folder, re-fetching ' \
41
+ 'history back to the oldest message in the old folder. Older history may still exist only in the ' \
42
+ "old folders, so keep them until you've checked the new ones."
43
+ end
44
+
45
+ def dir_lines(dirs) = dirs.map { |path, ids| " #{path} (#{ids.length} chat#{'s' unless ids.one?})" }
46
+
47
+ def report_dir_move(move)
48
+ line = dir_move_line(move, @state)
49
+ return unless line
50
+
51
+ (@dir_moves ||= []) << move
52
+ move.kind == :repoint ? warn(" Folder: #{line}") : info(" Folder: #{line}")
53
+ end
54
+
55
+ def dir_move_line(move, state)
56
+ note = move_note(move.to_h, state)
57
+ "#{move}#{note}" if note
58
+ end
59
+
60
+ def move_note(move_attrs, state)
61
+ kind, chat_id = move_attrs.values_at(:kind, :chat_id)
62
+ return MOVE_NOTES[kind] unless kind == :split
63
+
64
+ start = resync_start(state, chat_id).strftime('%Y-%m-%d')
65
+ " (split from a shared folder, which is left in place; re-fetching from #{start})"
66
+ end
67
+
68
+ # A detached chat re-syncs from the oldest message in its old folder, or --since if earlier
69
+ def resync_start(state, chat_id) = [@sync_store.resync_from(state, chat_id), since_time].compact.min
70
+
71
+ def show_folder_summary
72
+ write_dir_map
73
+ show_legacy_dirs(@detached_dirs)
74
+ end
75
+
76
+ def write_dir_map
77
+ moves = @dir_moves
78
+ return unless @options[:migrate_dirs] && moves
79
+
80
+ info(" Folder map: #{@sync_store.write_dir_map(moves)}")
81
+ end
82
+
83
+ def show_legacy_dirs(exclude)
84
+ legacy = @sync_store.legacy_shared_dirs(@state).except(*exclude)
85
+ return if legacy.empty?
86
+
87
+ info("Old shared folders still on disk (they may hold history the new folders don't):\n" \
88
+ "#{dir_lines(legacy).join("\n")}")
89
+ end
90
+
91
+ # Plans on a copy of the state, so nothing changes on disk or in sync_state.json
92
+ def show_dir_plan(syncable)
93
+ plan = JSON.parse(JSON.generate(@state))
94
+ detached = @sync_store.detach_shared_dirs(plan, only: @options[:chat_id])
95
+ lines = syncable.filter_map do |chat_data|
96
+ dir_move_line(@sync_store.plan_chat_dir(plan, dir_info(Models::Chat.from_api(chat_data))), plan)
97
+ end
98
+ show_planned_moves(lines)
99
+ show_planned_detaches(detached)
100
+ show_legacy_dirs([])
101
+ end
102
+
103
+ def show_planned_moves(lines)
104
+ puts
105
+ return info('Folder changes: none') if lines.empty?
106
+
107
+ info("Folder changes (#{lines.length}):\n#{lines.map { |line| " #{line}" }.join("\n")}")
108
+ end
109
+
110
+ def show_planned_detaches(detached)
111
+ return if detached.empty?
112
+
113
+ info("Shared folders that would be detached (#{detached.length}):\n" \
114
+ "#{dir_lines(detached).join("\n")}\n#{shared_dirs_plan}")
115
+ end
116
+ end
117
+ end
118
+ end