slk 0.10.0 → 0.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eec8154512ee9815597065ac68471bfd2ad55535191d4057e55ae4d942f15f66
4
- data.tar.gz: 3fe2bdbe0fa65ce0b0ee4a529531a38c59f78c674eed08f22d5d40b833b79150
3
+ metadata.gz: c7db83e915de4b86bafd0cc62e8a67fdaee0dcd9209cb94a1f8fd6a5a2704c46
4
+ data.tar.gz: eb25d2aafaa23a51016e64c2f5eb40c0117aeacb829d784caff9a50078946992
5
5
  SHA512:
6
- metadata.gz: e9a595db19f0748ccb42a4ec436dffa0fe4bbb4d1fbff0514f659cee09a8fb1db2c5258cdd7e74252972c2029e63de0082a2cadf33d96f98dff1681717680e88
7
- data.tar.gz: 0e33aa2ec6319fe88433dffe30865d0c17306da34cf136aebd1e9b17e9bb42d9bc5447748914751a74e48f43883bfe3cc404fb657cfaf33c2abdf7db369d2e9a
6
+ metadata.gz: 2d928dbf2906c22dc3ad108503c6ef4bfaeb68c29c0ed562dcd27e3515cf1468df426801dc6f7e73a3c2f9bb47d79fd7bf90054b3a9fc424453a35e8f60fd75d
7
+ data.tar.gz: 93850e6e114cd1634ce2546631b1c370eb94731ff284eb29c05c458a533f16fb1229afde2ca27f60e6ea2e00d12828d6db17342f1d336350cc59d5ae788cc1bb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.11.0] - 2026-09-23
11
+
12
+ ### Changed
13
+
14
+ - `slk sent` now expands your sent-message search hits into conversations: full paginated threads and DMs, merged channel windows (5 messages before and 30 minutes after your posts), and replies to your top-level posts. It groups by conversation and caps displayed messages with `--max` (default 200, `0` for all). `--before` and `--after-minutes` tune channel windows. `--mine` keeps the previous flat timeline and JSON shape.
15
+ - `slk sent --json` now returns `{date, range, conversations}`. Each conversation includes its workspace, channel, type, thread timestamp, `last_speaker_is_me`, `dropped_messages`, and chronological messages with sender, name, text, and `mine`. `channel_name` remains Slack's raw value; `channel_label` is the resolved display label for DMs and channels. The sender-only format remains available through `--mine --json`, with `channel_label` added to counts.
16
+
17
+ - `slk sent` text now wraps to the interactive terminal's width (or `--width N`), with indented reply and context continuations; `--no-wrap` disables wrapping. The redundant `▶` marker on your own messages and the last-speaker status lines are gone from text output; JSON still includes `last_speaker_is_me`. Thread headings now show the thread ID instead of repeating the parent message; channel and DM headings keep their readable names. A thin divider separates conversations without splitting context from new messages. Orphaned replies put their parent reference on a separate line so long thread IDs do not steal the message's wrap width.
18
+
19
+ ### Added
20
+
21
+ - `slk sent --changed-since TIME` shows a stateless diff of conversations you participated in over `--lookback DAYS` (default 7). TIME accepts local `H:MM` or `HH:MM`, ISO datetime, Slack epoch/ts, or `90m`/`2h`/`1d`; clock times use the latest local occurrence (yesterday when that time has not arrived today). Search discovers conversations, but exact-timestamp history and replies determine what changed: old threads and DMs with new replies appear even when you did not post today. Channel chatter alone does not count; channel windows are fetched only for your own new top-level posts. Changed output orders conversations by latest activity, newest at the bottom, and includes up to `--context N` earlier messages (default 2). Text marks older context with `·` without a separate cutoff divider; JSON labels each new message. JSON adds `changed_since: {iso, ts}`, `lookback_days`, per-conversation `new_count` and `new_from_others`, and per-message `new`, retaining the flat, timestamp-sorted `thread_ts` contract. Thread subscriptions contribute unread followed threads when available. A paginated history scan per watched channel identifies active thread roots from Slack's `latest_reply` (rather than probing every root); quiet DMs use a one-message history probe. Edits/reactions do not change message timestamps; conversations you never posted in and very recent posts missing from Slack's search index remain blind spots.
22
+ - `slk sent [today|yesterday|YYYY-MM-DD]` finds conversations you posted in, across workspaces by default. `--since YYYY-MM-DD` searches through today, `-w` selects a workspace, and `--mine` shows only your indexed messages in a flat chronological timeline with per-channel counts. Slack's index can lag, excludes deleted messages, and uses your profile timezone for date operators; DM history bounds use your local timezone.
23
+
24
+ ### Fixed
25
+
26
+ - `slk sent` now orders conversations with exact Slack timestamps and workspace/channel/thread tie-breakers, so identical start times stay deterministic across platforms.
27
+ - `slk sent --changed-since` no longer silently omits followed threads when their lookup fails due to network, rate-limit, authentication, or malformed-response errors. Unsupported or unauthorized-to-use subscription views still fall back to search-derived conversations. Sender lookup caches the authenticated user ID and reports a workspace-specific API error if Slack omits it. Deleted threads are recognized by Slack's error code, not message text, and invalid-time errors are limited to actual input conversions. Text wrapping falls back to 72 columns if a terminal cannot report its width.
28
+ - `slk sent` now includes the first post of each channel window in Slack history (and expands its replies), displays DM names even for threads, resolves group-DM participants, and shows file-only thread parents through file labels. Expanded replies now appear nested beneath their parent in text output, while JSON remains flat and timestamp-sorted with `thread_ts` links.
29
+ - Attachment and unfurl preview titles, authors, text, and image labels now decode HTML entities like message bodies.
30
+ - `slk search --all` now searches every workspace and tags each result. Search paginates past Slack's 100-result page size when `-n` requests more, and warns when the requested limit cuts off matches.
31
+
8
32
  ## [0.10.0] - 2026-09-18
9
33
 
10
34
  ### Added
@@ -15,17 +15,21 @@ module Slk
15
15
  @api.post(@workspace, 'conversations.list', params)
16
16
  end
17
17
 
18
- def history(channel:, limit: 20, cursor: nil, oldest: nil, latest: nil)
18
+ # rubocop:disable Metrics/ParameterLists
19
+ def history(channel:, limit: 20, cursor: nil, oldest: nil, latest: nil, inclusive: false)
19
20
  params = { channel: channel, limit: limit }
20
21
  params[:cursor] = cursor if cursor
21
22
  params[:oldest] = oldest if oldest
22
23
  params[:latest] = latest if latest
24
+ params[:inclusive] = true if inclusive
23
25
  @api.post(@workspace, 'conversations.history', params)
24
26
  end
27
+ # rubocop:enable Metrics/ParameterLists
25
28
 
26
- def replies(channel:, timestamp:, limit: 100, cursor: nil)
29
+ def replies(channel:, timestamp:, limit: 100, cursor: nil, oldest: nil)
27
30
  params = { channel: channel, ts: timestamp, limit: limit }
28
31
  params[:cursor] = cursor if cursor
32
+ params[:oldest] = oldest if oldest
29
33
  # Use form encoding - some workspaces (Enterprise Grid) require it
30
34
  @api.post_form(@workspace, 'conversations.replies', params)
31
35
  end
data/lib/slk/cli.rb CHANGED
@@ -15,6 +15,7 @@ module Slk
15
15
  'activity' => Commands::Activity,
16
16
  'later' => Commands::Later,
17
17
  'search' => Commands::Search,
18
+ 'sent' => Commands::Sent,
18
19
  'preset' => Commands::Preset,
19
20
  'workspaces' => Commands::Workspaces,
20
21
  'cache' => Commands::Cache,
@@ -12,6 +12,7 @@ module Slk
12
12
  ['dnd', 'Manage Do Not Disturb'],
13
13
  ['messages', 'Read channel or DM messages'],
14
14
  ['search', 'Search messages across channels'],
15
+ ['sent', 'Show your sent messages across workspaces'],
15
16
  ['unread', 'View and clear unread messages'],
16
17
  ['activity', 'Show activity feed (reactions, mentions, threads)'],
17
18
  ['later', 'Show saved "Later" items'],
@@ -84,6 +85,7 @@ module Slk
84
85
  slk status schedule "Vet" 1p-3p Schedule a status (am/pm or 24h)
85
86
  slk dnd 1h Enable DND for 1 hour
86
87
  slk messages #general Read channel messages
88
+ slk sent Show messages you sent today
87
89
  slk preset meeting Apply preset
88
90
  EXAMPLES
89
91
  end
@@ -41,8 +41,8 @@ module Slk
41
41
  # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
42
42
  def handle_option(arg, args, remaining)
43
43
  case arg
44
- when '-n', '--limit' then @options[:limit] = require_value(arg, args).to_i
45
- when '--page' then @options[:page] = require_value(arg, args).to_i
44
+ when '-n', '--limit' then @options[:limit] = positive_integer(arg, args)
45
+ when '--page' then @options[:page] = positive_integer(arg, args)
46
46
  when '--in' then @options[:in_channel] = require_value(arg, args)
47
47
  when '--from' then @options[:from_user] = require_value(arg, args)
48
48
  when '--after' then @options[:after_date] = require_value(arg, args)
@@ -56,11 +56,19 @@ module Slk
56
56
 
57
57
  def require_value(option, args)
58
58
  value = args.shift
59
- raise ArgumentError, "#{option} requires a value" unless value
59
+ raise ArgumentError, "#{option} requires a value" unless value && !value.start_with?('-')
60
60
 
61
61
  value
62
62
  end
63
63
 
64
+ def positive_integer(option, args)
65
+ value = require_value(option, args)
66
+ number = Integer(value, exception: false)
67
+ raise ArgumentError, "#{option} expects a positive integer" unless number&.positive?
68
+
69
+ number
70
+ end
71
+
64
72
  def help_text
65
73
  help = Support::HelpFormatter.new('slk search <query> [options]')
66
74
  help.description('Search messages across channels and DMs.')
@@ -84,11 +92,12 @@ module Slk
84
92
 
85
93
  def add_options_section(help)
86
94
  help.section('OPTIONS') do |s|
87
- s.option('-n, --limit N', 'Number of results (default: 20, max: 100)')
88
- s.option('--page N', 'Page number for pagination')
95
+ s.option('-n, --limit N', 'Results per workspace (default: 20; paginates past 100)')
96
+ s.option('--page N', 'Starting page in each workspace')
89
97
  s.option('--threads', 'Show thread replies inline')
90
98
  s.option('--json', 'Output as JSON')
91
- s.option('-w, --workspace', 'Specify workspace')
99
+ s.option('-w, --workspace NAME', 'Specify workspace')
100
+ s.option('--all', 'Search every workspace (results tagged by workspace)')
92
101
  s.option('-v, --verbose', 'Show debug information')
93
102
  s.option('-q, --quiet', 'Suppress output')
94
103
  end
@@ -110,23 +119,19 @@ module Slk
110
119
  1
111
120
  end
112
121
 
113
- # rubocop:disable Metrics/MethodLength
114
122
  def search_and_display(query)
115
- workspace = target_workspaces.first
116
- full_query = build_query(query)
123
+ raise ArgumentError, 'Use either --all or --workspace, not both' if @options[:all] && @options[:workspace]
117
124
 
125
+ full_query = build_query(query)
118
126
  debug("Searching: #{full_query}")
119
- response = runner.search_api(workspace.name).messages(
120
- query: full_query,
121
- count: @options[:limit],
122
- page: @options[:page]
123
- )
124
-
125
- results = parse_results(response)
126
- display_results(results, workspace, response)
127
+ searches = target_workspaces.map do |workspace|
128
+ [workspace, Services::SearchPages.new(runner.search_api(workspace.name)).fetch(
129
+ query: full_query, limit: @options[:limit], page: @options[:page]
130
+ )]
131
+ end
132
+ display_results(searches)
127
133
  0
128
134
  end
129
- # rubocop:enable Metrics/MethodLength
130
135
 
131
136
  def build_query(base_query)
132
137
  parts = [base_query]
@@ -138,47 +143,50 @@ module Slk
138
143
  parts.join(' ')
139
144
  end
140
145
 
141
- def parse_results(response)
142
- matches = response.dig('messages', 'matches') || []
143
- matches.map { |m| Models::SearchResult.from_api(m) }
144
- end
145
-
146
- def display_results(results, workspace, response)
146
+ def display_results(searches)
147
147
  if @options[:json]
148
- output_json_results(results, response)
148
+ output_json_results(searches)
149
149
  else
150
- display_text_results(results, workspace, response)
150
+ display_text_results(searches)
151
151
  end
152
152
  end
153
153
 
154
- def output_json_results(results, response)
155
- pagination = response.dig('messages', 'pagination') || {}
156
- output_json({
157
- results: results.map(&:to_h),
158
- pagination: {
159
- page: pagination['page'],
160
- page_count: pagination['page_count'],
161
- total_count: pagination['total_count']
162
- }
163
- })
154
+ def output_json_results(searches)
155
+ results = searches.flat_map do |workspace, data|
156
+ data[:results].map { |result| result.to_h.merge(workspace: workspace.name) }
157
+ end
158
+ output_json(results: results, pagination: json_pagination(searches))
164
159
  end
165
160
 
166
- def display_text_results(results, workspace, response)
167
- show_pagination_info(response) if @options[:verbose]
168
-
169
- if results.empty?
170
- puts 'No results found.'
171
- return
161
+ def json_pagination(searches)
162
+ pages = searches.to_h do |workspace, data|
163
+ [workspace.name, data[:pagination].merge('truncated' => data[:truncated] == true)]
172
164
  end
165
+ searches.length == 1 ? pages.values.first : pages
166
+ end
173
167
 
174
- results.each_with_index do |result, index|
168
+ def display_text_results(searches)
169
+ searches.each { |workspace, data| report_search(workspace, data) }
170
+ entries = searches.flat_map { |workspace, data| data[:results].map { |result| [result, workspace] } }
171
+ return puts 'No results found.' if entries.empty?
172
+
173
+ entries.each_with_index do |(result, workspace), index|
175
174
  display_single_result(result, workspace)
176
- puts if index < results.length - 1
175
+ puts if index < entries.length - 1
177
176
  end
178
177
  end
179
178
 
179
+ def report_search(workspace, data)
180
+ show_pagination_info(data[:pagination], workspace) if @options[:verbose]
181
+ return unless data[:truncated]
182
+
183
+ total = data[:pagination]['total_count']
184
+ quantity = total ? "#{data[:results].length} of #{total}" : data[:results].length.to_s
185
+ warn("#{workspace.name}: Showing #{quantity} matches; raise -n for more.")
186
+ end
187
+
180
188
  def display_single_result(result, workspace)
181
- runner.search_formatter.display_result(result, workspace, format_options)
189
+ runner.search_formatter.display_result(result, workspace, format_options.merge(workspace_label: @options[:all]))
182
190
  show_thread_replies(result, workspace) if should_show_thread?(result)
183
191
  end
184
192
 
@@ -209,13 +217,12 @@ module Slk
209
217
  lines[1..].each { |line| puts " #{line}" }
210
218
  end
211
219
 
212
- def show_pagination_info(response)
213
- pagination = response.dig('messages', 'pagination') || {}
220
+ def show_pagination_info(pagination, workspace)
214
221
  total = pagination['total_count'] || 0
215
222
  page = pagination['page'] || 1
216
223
  page_count = pagination['page_count'] || 1
217
224
 
218
- debug("Page #{page}/#{page_count} (#{total} total results)")
225
+ debug("#{workspace.name}: Page #{page}/#{page_count} (#{total} total results)")
219
226
  end
220
227
  end
221
228
  # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Commands
5
+ # All indexed messages sent by the authenticated user on a date/range.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class Sent < Base
8
+ def execute
9
+ result = validate_options
10
+ return result if result
11
+
12
+ run_sent
13
+ rescue ApiError => e
14
+ error("Sent search failed: #{e.message}")
15
+ 1
16
+ end
17
+
18
+ protected
19
+
20
+ def default_options
21
+ super.merge(since: nil, mine: false, changed_since: nil, lookback: 7, context: 2,
22
+ before: Services::SentConversations::DEFAULT_BEFORE,
23
+ after_minutes: Services::SentConversations::DEFAULT_AFTER_MINUTES,
24
+ max: Services::SentConversations::DEFAULT_MAX)
25
+ end
26
+
27
+ def default_width
28
+ return nil unless $stdout.tty?
29
+
30
+ columns = IO.console&.winsize&.last
31
+ columns&.positive? ? columns : super
32
+ rescue SystemCallError, IOError, NotImplementedError
33
+ super
34
+ end
35
+
36
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
37
+ def handle_option(arg, args, remaining)
38
+ case arg
39
+ when '--since' then @options[:since] = option_value(arg, args)
40
+ when '--changed-since' then @options[:changed_since] = option_value(arg, args)
41
+ when '--lookback' then @options[:lookback] = positive_integer(arg, args)
42
+ when '--context' then @options[:context] = nonnegative_integer(arg, args)
43
+ when '--mine' then @options[:mine] = true
44
+ when '--before' then @options[:before] = before_limit(arg, args)
45
+ when '--after-minutes' then @options[:after_minutes] = nonnegative_integer(arg, args)
46
+ when '--max' then @options[:max] = nonnegative_integer(arg, args)
47
+ else super
48
+ end
49
+ end
50
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
51
+
52
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
53
+ def help_text
54
+ help = Support::HelpFormatter.new('slk sent [today|yesterday|YYYY-MM-DD] [options]')
55
+ help.description('Catch up on conversations you posted in, across all workspaces.')
56
+ help.note('Search is indexed: very recent and deleted messages may be missing.')
57
+ help.note('Dates/DM history bounds use your local clock; Slack search uses your profile timezone.')
58
+ help.note('Long --since ranges can be slow (search is rate-limited; 429s retry once).')
59
+ help.note('Conversations you only read or were mentioned in but never posted in are out of scope.')
60
+ help.note('A future --mentions flag could seed mentioned conversations via to:me search.')
61
+ help.note('JSON: {date, range, conversations:[{workspace, channel_id, channel_name, channel_label, type,')
62
+ help.note('thread_ts, last_speaker_is_me, dropped_messages, messages:[{ts, user,')
63
+ help.note('user_name, text, mine, thread_ts}]}]}; --mine keeps counts/results JSON,')
64
+ help.note('with channel_label added to counts. channel_name remains the raw Slack name.')
65
+ help.note('JSON messages stay flat and timestamp-sorted; thread_ts links replies to their parent.')
66
+ help.note('Text output nests expanded replies under their parent, even when sent later, and wraps')
67
+ help.note('to the terminal width on a TTY. Use --width N to override or --no-wrap to disable.')
68
+ help.note('Thread headings show the thread ID; channel and DM headings keep their readable names.')
69
+ help.note('A thin divider separates conversations, not messages within a conversation.')
70
+ help.note('For --changed-since, H:MM or HH:MM means the most recent local occurrence: if that time has not')
71
+ help.note('arrived today, it means yesterday. Dated timestamps in the future are rejected.')
72
+ help.note('--changed-since is stateless: exact-ts history/replies detect new messages; search only finds')
73
+ help.note('the watch set. Edits/reactions do not change ts and are invisible. Search index lag may')
74
+ help.note('omit posts from the last few minutes. Conversations you never posted in remain out of scope.')
75
+ help.note('Unread followed threads supplement the watch set when available; --lookback must include')
76
+ help.note('the days you posted in any other threads you want to watch.')
77
+ help.note('Changed text marks earlier context with · and puts the most recently active conversation last.')
78
+ help.note('A wide lookback checks many thread roots and can take minutes under Slack rate limits.')
79
+ help.note('Changed JSON adds changed_since:{iso, ts}, lookback_days, new_count, new_from_others,')
80
+ help.note('and per-message new. Its messages stay flat and timestamp-sorted with thread_ts links.')
81
+ help.section('OPTIONS') do |s|
82
+ s.option('--since YYYY-MM-DD', 'From this date through today (inclusive)')
83
+ s.option('--changed-since TIME', 'Only changed conversations (H:MM/HH:MM, ISO, epoch, 90m, 2h, 1d)')
84
+ s.option('--lookback DAYS', 'Days of sent messages to watch (default: 7; changed mode)')
85
+ s.option('--context N', 'Earlier messages per changed conversation (default: 2)')
86
+ s.option('--mine', 'Only your messages, flat timeline with counts (previous behavior)')
87
+ s.option('--before N', 'Channel messages before a window (default: 5, max: 200)')
88
+ s.option('--after-minutes N', 'Channel window after each post (default: 30)')
89
+ s.option('--max N', 'Messages per conversation (default: 200; 0 for all)')
90
+ s.option('-w, --workspace NAME', 'Search one workspace instead of all')
91
+ s.option('--all', 'Search all workspaces (default)')
92
+ s.option('--json', 'Output conversations as JSON (or counts/results with --mine)')
93
+ s.option('--width N', 'Wrap text at N columns (default: terminal width on TTY)')
94
+ s.option('--no-wrap', 'Disable text wrapping')
95
+ end
96
+ help.section('EXAMPLES') do |s|
97
+ s.example('slk sent', 'Today in all workspaces')
98
+ s.example('slk sent yesterday', 'Yesterday in all workspaces')
99
+ s.example('slk sent 2026-09-19', 'One specific day')
100
+ s.example('slk sent --since 2026-09-15', 'From Sep 15 through today')
101
+ s.example('slk sent --mine --json', 'Only your sent messages, flat JSON')
102
+ s.example('slk sent --changed-since 15:17 --lookback 3', 'Diff watched conversations since 15:17')
103
+ end
104
+ help.render
105
+ end
106
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
107
+
108
+ private
109
+
110
+ def run_sent
111
+ raise UsageError, 'Use either --all or --workspace, not both.' if @options[:all] && @options[:workspace]
112
+
113
+ return run_changes if @options[:changed_since]
114
+
115
+ query = date_query
116
+ entries = collect_entries(query)
117
+ @options[:mine] ? emit(entries, query) : emit_context(entries)
118
+ 0
119
+ end
120
+
121
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
122
+ def run_changes
123
+ validate_changed_options
124
+ since = Support::CheckInTime.parse(@options[:changed_since])
125
+ entries = collect_entries(changed_query)
126
+ changes = Services::SentChanges.new(
127
+ runner: runner, since: since, context: @options[:context], max: @options[:max],
128
+ before: @options[:before], after_minutes: @options[:after_minutes]
129
+ ).collect(entries, workspaces: selected_workspaces)
130
+ Formatters::SentFormatter.new(runner: runner, options: format_options).display_changes(
131
+ changes, changed_since: since, lookback_days: @options[:lookback], json: @options[:json]
132
+ )
133
+ 0
134
+ end
135
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
136
+
137
+ def validate_changed_options
138
+ if positional_args.any? || @options[:since]
139
+ raise UsageError, 'Use --changed-since alone, not with a date or --since.'
140
+ end
141
+ raise UsageError, 'Use --changed-since without --mine.' if @options[:mine]
142
+ end
143
+
144
+ def changed_query
145
+ start_date = Date.today - (@options[:lookback] - 1)
146
+ "from:me after:#{start_date - 1} before:#{Date.today + 1}"
147
+ end
148
+
149
+ def positive_integer(flag, args)
150
+ number = nonnegative_integer(flag, args)
151
+ raise UsageError, "#{flag} expects a positive integer." if number.zero?
152
+
153
+ number
154
+ end
155
+
156
+ def before_limit(flag, args)
157
+ number = nonnegative_integer(flag, args)
158
+ raise UsageError, '--before must be at most 200.' if number > 200
159
+
160
+ number
161
+ end
162
+
163
+ def nonnegative_integer(flag, args)
164
+ value = option_value(flag, args)
165
+ number = Integer(value, exception: false)
166
+ raise UsageError, "#{flag} expects a non-negative integer." unless number && !number.negative?
167
+
168
+ number
169
+ end
170
+
171
+ def date_query
172
+ raise UsageError, 'Use either a date or --since, not both.' if @options[:since] && positional_args.any?
173
+ raise UsageError, 'Expected one date: today, yesterday, or YYYY-MM-DD.' if positional_args.length > 1
174
+
175
+ @options[:since] ? range_query : day_query
176
+ end
177
+
178
+ def range_query
179
+ since = parse_date(@options[:since])
180
+ raise UsageError, '--since must not be in the future.' if since > Date.today
181
+
182
+ "from:me after:#{since - 1} before:#{Date.today + 1}"
183
+ end
184
+
185
+ def day_query
186
+ date = case positional_args.first
187
+ when nil, 'today' then Date.today
188
+ when 'yesterday' then Date.today - 1
189
+ else parse_date(positional_args.first)
190
+ end
191
+ "from:me on:#{date}"
192
+ end
193
+
194
+ def parse_date(value)
195
+ raise UsageError, "Invalid date: #{value.inspect}. Use YYYY-MM-DD." unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
196
+
197
+ Date.iso8601(value)
198
+ rescue Date::Error
199
+ raise UsageError, "Invalid date: #{value.inspect}. Use YYYY-MM-DD."
200
+ end
201
+
202
+ def collect_entries(query)
203
+ entries = selected_workspaces.flat_map { |workspace| fetch_workspace(workspace, query) }
204
+ entries.sort_by { |workspace, result| [result.ts.to_f, workspace.name, result.channel_id.to_s] }
205
+ end
206
+
207
+ def selected_workspaces
208
+ @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
209
+ end
210
+
211
+ def fetch_workspace(workspace, query)
212
+ pages = Services::SearchPages.new(runner.search_api(workspace.name)).fetch(query: query, sort_dir: 'asc')
213
+ pages[:results].map { |result| [workspace, result] }
214
+ end
215
+
216
+ def emit_context(entries)
217
+ start_date, end_date = selected_dates
218
+ conversations = Services::SentConversations.new(
219
+ runner: runner, start_date: start_date, end_date: end_date,
220
+ before: @options[:before], after_minutes: @options[:after_minutes], max: @options[:max]
221
+ ).collect(entries)
222
+ Formatters::SentFormatter.new(runner: runner, options: format_options).display(
223
+ conversations, start_date: start_date, end_date: end_date, json: @options[:json]
224
+ )
225
+ end
226
+
227
+ def selected_dates
228
+ return [parse_date(@options[:since]), Date.today] if @options[:since]
229
+
230
+ date = case positional_args.first
231
+ when nil, 'today' then Date.today
232
+ when 'yesterday' then Date.today - 1
233
+ else parse_date(positional_args.first)
234
+ end
235
+ [date, date]
236
+ end
237
+
238
+ def emit(entries, query)
239
+ counts = entries.group_by { |workspace, result| [workspace.name, result.channel_id] }
240
+ if @options[:json]
241
+ output_json(query: query, counts: json_counts(counts), results: entries.map do |workspace, result|
242
+ result.to_h.merge(workspace: workspace.name)
243
+ end)
244
+ else
245
+ display_counts(counts, entries)
246
+ end
247
+ end
248
+
249
+ def json_counts(counts)
250
+ counts.map do |(workspace, channel_id), group|
251
+ result = group.first.last
252
+ { workspace: workspace, channel_id: channel_id, channel_name: result.channel_name,
253
+ channel_label: destination_label(runner.workspace(workspace), result),
254
+ channel_type: result.channel_type, count: group.size }
255
+ end
256
+ end
257
+
258
+ def display_counts(counts, entries)
259
+ if entries.empty?
260
+ puts 'No sent messages found.'
261
+ return
262
+ end
263
+
264
+ counts.each_value { |group| display_count(group) }
265
+ puts
266
+ entries.each { |workspace, result| display_entry(workspace, result) }
267
+ end
268
+
269
+ def display_entry(workspace, result)
270
+ options = format_options.merge(workspace_label: true, channel_label: destination_label(workspace, result))
271
+ runner.search_formatter.display_result(result, workspace, options)
272
+ end
273
+
274
+ def display_count(group)
275
+ workspace, result = group.first
276
+ puts "[#{workspace.name}] #{destination_label(workspace, result)}: #{group.size}"
277
+ end
278
+
279
+ def destination_label(workspace, result)
280
+ runner.sent_channel_label.label(
281
+ workspace: workspace, type: result.channel_type, name: result.channel_name,
282
+ channel_id: result.channel_id, self_user_id: result.user_id, self_username: result.username
283
+ )
284
+ end
285
+ end
286
+ # rubocop:enable Metrics/ClassLength
287
+ end
288
+ end
@@ -32,7 +32,7 @@ module Slk
32
32
  format_author(attachment, lines)
33
33
  format_text(att_text, lines, options) if att_text && block_images.empty?
34
34
  format_image(attachment, lines, options, message_ts: message_ts, index: index) if image_url
35
- block_images.each { |img| lines << "> [Image: #{img}]" }
35
+ format_block_images(block_images, lines)
36
36
  end
37
37
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
38
38
 
@@ -46,9 +46,13 @@ module Slk
46
46
  end
47
47
  end
48
48
 
49
+ def format_block_images(images, lines)
50
+ images.each { |img| lines << "> [Image: #{@text_processor.call(img)}]" }
51
+ end
52
+
49
53
  def format_author(attachment, lines)
50
54
  author = attachment['author_name'] || attachment['author_subname']
51
- lines << "> #{@output.bold(author)}:" if author
55
+ lines << "> #{@output.bold(@text_processor.call(author))}:" if author
52
56
  end
53
57
 
54
58
  def format_text(att_text, lines, options)
@@ -73,7 +77,7 @@ module Slk
73
77
  else
74
78
  image_url = attachment['image_url'] || attachment['thumb_url']
75
79
  filename = attachment['title'] || extract_filename(image_url)
76
- lines << "> [Image: #{filename}]"
80
+ lines << "> [Image: #{@text_processor.call(filename)}]"
77
81
  end
78
82
  end
79
83
 
@@ -23,25 +23,38 @@ module Slk
23
23
  # Display a single search result
24
24
  def display_result(result, workspace, options = {})
25
25
  timestamp = @output.blue("[#{format_time(result.timestamp)}]")
26
- channel = @output.cyan(resolve_channel(result, workspace))
26
+ channel = @output.cyan(display_channel_label(result, workspace, options))
27
27
  user = @output.bold("#{resolve_user(result, workspace)}:")
28
28
  text = prepare_text(result.text, workspace, options)
29
29
 
30
- @output.puts "#{timestamp} #{channel} #{user} #{text}"
31
- display_files(result.files) if result.files&.any?
30
+ workspace_label = workspace_prefix(workspace, options)
31
+ @output.puts "#{timestamp} #{workspace_label}#{channel} #{user} #{text}"
32
+ display_files(result.files, workspace, options) if result.files&.any?
32
33
  end
33
34
 
34
- private
35
+ # Channel label used by search timelines and per-channel summaries.
36
+ def channel_label(result, workspace)
37
+ channel_label_for(result.channel_type, result.channel_name, workspace)
38
+ end
35
39
 
36
- def resolve_channel(result, workspace)
37
- if result.dm?
38
- # For DMs, channel_name is a user ID - resolve it
39
- @mentions.replace("<@#{result.channel_name}>", workspace)
40
+ def channel_label_for(type, name, workspace)
41
+ if %w[im mpim].include?(type)
42
+ @mentions.replace("<@#{name}>", workspace)
40
43
  else
41
- "##{result.channel_name}"
44
+ "##{name}"
42
45
  end
43
46
  end
44
47
 
48
+ private
49
+
50
+ def display_channel_label(result, workspace, options)
51
+ options[:channel_label] || channel_label(result, workspace)
52
+ end
53
+
54
+ def workspace_prefix(workspace, options)
55
+ options[:workspace_label] ? "#{@output.cyan("[#{workspace.name}]")} " : ''
56
+ end
57
+
45
58
  def resolve_user(result, workspace)
46
59
  # If we have a username, use it directly
47
60
  return result.username if result.username && !result.username.empty?
@@ -57,9 +70,10 @@ module Slk
57
70
  @text_processor.process(text, workspace, options)
58
71
  end
59
72
 
60
- def display_files(files)
73
+ def display_files(files, workspace, options)
61
74
  files.each do |file|
62
- @output.puts @output.blue("[Image: #{file[:name]}]")
75
+ name = prepare_text(file[:name], workspace, options)
76
+ @output.puts @output.blue("[Image: #{name}]")
63
77
  end
64
78
  end
65
79