slk 0.9.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: c56010125fc8e78293e4701ac5619742dda1fecd3e6e06ec98a3eb6afab0dc46
4
- data.tar.gz: 05fad0411c6b216091bc9dd7cade46a36579bbba951042c61859111c57db9356
3
+ metadata.gz: c7db83e915de4b86bafd0cc62e8a67fdaee0dcd9209cb94a1f8fd6a5a2704c46
4
+ data.tar.gz: eb25d2aafaa23a51016e64c2f5eb40c0117aeacb829d784caff9a50078946992
5
5
  SHA512:
6
- metadata.gz: 2ae292c3c50580e4872ec7a2334052b2495d0df8943341970d98f0a1922ea5b15d9612a0b8998de30bfb100100d3daf0567c9270fafef89529fc88c65f72634d
7
- data.tar.gz: e86c76ce090e6a622a2122c068fb8a384520ff7b38193613f254f446f4a4165f09c48bcf944b2f3d5a2ad3435a0e71502bda8a6ec1b776b0b1b4bbc1ee240424
6
+ metadata.gz: 2d928dbf2906c22dc3ad108503c6ef4bfaeb68c29c0ed562dcd27e3515cf1468df426801dc6f7e73a3c2f9bb47d79fd7bf90054b3a9fc424453a35e8f60fd75d
7
+ data.tar.gz: 93850e6e114cd1634ce2546631b1c370eb94731ff284eb29c05c458a533f16fb1229afde2ca27f60e6ea2e00d12828d6db17342f1d336350cc59d5ae788cc1bb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,53 @@ 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
+
32
+ ## [0.10.0] - 2026-09-18
33
+
34
+ ### Added
35
+
36
+ - **`slk deactivations --tenure`** — how long each person stayed
37
+ - Start dates come from the workspace's "Start Date" custom profile field, discovered from the team schema rather than hardcoded, since every workspace numbers its own fields. A date-typed field wins over a text one with the same label
38
+ - `users.list` does not carry custom fields, so this costs one `users.profile.get` per person, and Slack rate-limits that endpoint to roughly eight calls a minute. The command says how long it will take before it starts, and only looks up the rows it is about to show
39
+ - Every answer is cached the moment it arrives, not at the end, so interrupting a long lookup keeps the work already paid for. Accounts with no start date on file are cached too, otherwise every run would pay again to learn the same nothing
40
+ - Tenure is counted in whole months: somebody who started on the 20th and left on the 3rd has not completed that month. An end date before the start is a data entry error rather than a negative tenure, and reads as blank
41
+ - Blank means nobody filled the field in, and the column disappears entirely when no start date is known. `--tenure` with `--chart` is refused rather than ignored — a histogram has no row to hang a tenure on
42
+ - **`slk deactivations --csv`** — the spreadsheet that always gets asked for
43
+ - Writes every match rather than the screenful `-n` would show: a truncated export is a wrong answer that looks like a right one
44
+ - With `--tenure` it gains `started_on`, `tenure_months` and a readable `tenure` column; unknown start dates leave empty cells rather than zeros, so averaging tenure in a spreadsheet skips them instead of counting people who left the day they arrived
45
+ - RFC 4180 quoting, hand-rolled, because Ruby 3.4 moved csv out of the default gems and this tool ships with no dependencies
46
+ - Progress and warnings go to stderr, so `slk deactivations --csv > file.csv` captures only data
47
+
48
+ ### Fixed
49
+
50
+ - **A cache that cannot be written no longer costs you the run.** `MetaCache.write` returns disk failures instead of raising them: a full or read-only disk means "no cache", not "no answer". This mattered most for start date lookups, which sit behind minutes of rate-limited calls, but it also fixed the deactivation roster scan, which crashed outright on an unwritable cache directory. Other exceptions still raise — a bug in the value being cached is not a disk problem
51
+ - **A cache that cannot be read no longer crashes.** An unreadable cache file now warns and is skipped, the same way a corrupt one already was. The file is left in place rather than deleted, since removing it needs the access that just failed
52
+ - **A malformed API response fails as an API error.** Slack replying with a JSON array, string or null instead of an object used to surface as `TypeError` or `NoMethodError` from whichever command happened to dig into it first
53
+ - **`slk cache clear` now clears all caches.** It removed only the user and channel caches while reporting "Cleared all caches"; start dates, deactivation rosters and resolved profiles survived it
54
+
8
55
  ## [0.9.0] - 2026-09-18
9
56
 
10
57
  ### Added
data/README.md CHANGED
@@ -217,6 +217,8 @@ slk deactivations # 25 most recent departures
217
217
  slk deactivations 90d # Everyone who left in the last 90 days
218
218
  slk deactivations 2026-01-01 -n 0 # All departures this year
219
219
  slk deactivations --chart # Departures per month
220
+ slk deactivations --tenure # Add how long each person stayed
221
+ slk deactivations 1y --csv # Spreadsheet export of a year of departures
220
222
  slk deactivations --grep engineer # Filter by name, handle, title, email, or ID
221
223
  slk deactivations --bots # Include deactivated bots and app users
222
224
  slk deactivations --json # Machine-readable output
@@ -240,6 +242,41 @@ forward, so treat it as "last touched" rather than a payroll record.
240
242
  The roster costs one API call per 1000 members, so the result is cached for six
241
243
  hours; `--refresh` re-fetches it.
242
244
 
245
+ #### Tenure
246
+
247
+ `--tenure` adds how long each person was here, taken from the workspace's
248
+ "Start Date" profile field:
249
+
250
+ ```
251
+ acme: 3 deactivated since 30d (664 active members)
252
+
253
+ 2026-09-14 Dana Whitfield 1mo Platform Support
254
+ 2026-09-11 Priya Raghunathan 2y 7mo UX Researcher
255
+ 2026-09-09 Sam Okonkwo 4y 10mo Senior Software Engineer
256
+ ```
257
+
258
+ This one is slow the first time. Start dates are not in the roster — they cost
259
+ one `users.profile.get` per person, and Slack rate-limits that endpoint to
260
+ roughly eight calls a minute, so a screenful takes about three minutes. The
261
+ command says so before it starts, only looks up the rows it is about to show,
262
+ and caches each answer the moment it arrives: interrupting it keeps the work
263
+ already paid for, and the second run is instant.
264
+
265
+ Blank means nobody filled the field in. Months are whole months, so somebody
266
+ who started on the 20th and left on the 3rd has not completed that month.
267
+
268
+ #### CSV
269
+
270
+ `--csv` writes every match — not just the screenful `-n` would show, since a
271
+ truncated export is a wrong answer that looks like a right one. Combine it with
272
+ `--tenure` for `started_on`, `tenure_months` and a readable `tenure` column:
273
+
274
+ ```bash
275
+ slk deactivations 1y --tenure --csv > departures.csv
276
+ ```
277
+
278
+ Progress and warnings go to stderr, so the redirect above captures only data.
279
+
243
280
  ### Global Options
244
281
 
245
282
  ```bash
@@ -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,
@@ -65,6 +65,14 @@ module Slk
65
65
  0
66
66
  end
67
67
 
68
+ # Every kind, including the meta cache behind start dates, deactivation
69
+ # rosters and resolved profiles — "cleared" should not have exceptions.
70
+ def clear_every_cache(workspace_name)
71
+ cache_store.clear_user_cache(*[workspace_name].compact)
72
+ cache_store.clear_channel_cache(*[workspace_name].compact)
73
+ cache_store.clear_meta_cache(*[workspace_name].compact)
74
+ end
75
+
68
76
  def display_workspace_status(workspace)
69
77
  puts output.bold(workspace.name) if target_workspaces.size > 1
70
78
  display_cache_counts(workspace)
@@ -87,15 +95,8 @@ module Slk
87
95
  end
88
96
 
89
97
  def clear_cache(workspace_name)
90
- if workspace_name
91
- cache_store.clear_user_cache(workspace_name)
92
- cache_store.clear_channel_cache(workspace_name)
93
- success("Cleared cache for #{workspace_name}")
94
- else
95
- cache_store.clear_user_cache
96
- cache_store.clear_channel_cache
97
- success('Cleared all caches')
98
- end
98
+ clear_every_cache(workspace_name)
99
+ success(workspace_name ? "Cleared cache for #{workspace_name}" : 'Cleared all caches')
99
100
 
100
101
  0
101
102
  end
@@ -8,16 +8,31 @@ module Slk
8
8
  # slk deactivations 90d # everyone who left in the last 90 days
9
9
  # slk deactivations --chart # departures per month
10
10
  # slk deactivations --grep engineer # filter by name, handle, title, email, ID
11
+ # slk deactivations --tenure # add how long each person stayed
12
+ # slk deactivations --csv # spreadsheet export of every match
11
13
  # rubocop:disable Metrics/ClassLength
12
14
  class Deactivations < Base
13
15
  DEFAULT_LIMIT = 25
14
16
  CHART_MONTHS = 12
17
+ # Measured against a live workspace: users.profile.get answers two or
18
+ # three calls in a row, then makes you wait out a thirty second
19
+ # Retry-After — about eight lookups a minute averaged over a long run,
20
+ # which is what the time estimate is built on.
21
+ LOOKUPS_PER_MINUTE = 8
22
+ COST_WARNING_AT = 5
23
+ SWITCHES = {
24
+ '--chart' => :chart, '--bots' => :bots, '--tenure' => :tenure, '--csv' => :csv,
25
+ '--refresh' => :refresh, '--no-cache' => :refresh
26
+ }.freeze
15
27
 
16
28
  def execute
17
29
  result = validate_options
18
30
  return result if result
19
31
 
20
32
  run
33
+ rescue Services::StartDateLookup::MissingFieldError => e
34
+ error(e.message)
35
+ 1
21
36
  rescue ApiError => e
22
37
  error("API error: #{e.message}")
23
38
  1
@@ -26,18 +41,21 @@ module Slk
26
41
  protected
27
42
 
28
43
  def handle_option(arg, args, _remaining)
44
+ return switch_on(SWITCHES[arg]) if SWITCHES.key?(arg)
45
+
29
46
  case arg
30
47
  when '-n', '--limit' then @options[:limit] = parse_limit(arg, option_value(arg, args))
31
48
  when '--since' then @options[:since] = option_value(arg, args)
32
- when '--chart' then @options[:chart] = true
33
- when '--bots' then @options[:bots] = true
34
49
  when '--grep' then @options[:grep] = option_value(arg, args)
35
- when '--refresh', '--no-cache' then @options[:refresh] = true
36
50
  else return super
37
51
  end
38
52
  true
39
53
  end
40
54
 
55
+ def switch_on(key)
56
+ @options[key] = true
57
+ end
58
+
41
59
  def help_text
42
60
  help = Support::HelpFormatter.new('slk deactivations [since] [options]')
43
61
  help.description('Show deactivated accounts — who left the workspace, and when.')
@@ -76,33 +94,50 @@ module Slk
76
94
  s.option('-n, --limit N', "Rows to show (default #{DEFAULT_LIMIT}, 0 for all)")
77
95
  s.option('--since SPEC', 'Only departures since 7d, 4w, 6m, or YYYY-MM-DD')
78
96
  s.option('--chart', 'Histogram of departures per month')
97
+ s.option('--tenure', 'Add how long each person stayed (slow: one lookup per person)')
79
98
  s.option('--grep PATTERN', 'Filter by name, handle, title, email, or user ID')
80
99
  s.option('--bots', 'Include deactivated bots and app users')
81
- s.option('--refresh', 'Re-fetch the roster instead of using the cache')
82
- s.option('--json', 'Raw JSON output')
100
+ add_output_options(s)
83
101
  end
84
102
  end
85
103
 
104
+ def add_output_options(section)
105
+ section.option('--refresh', 'Re-fetch the roster instead of using the cache')
106
+ section.option('--csv', 'CSV of every match, for a spreadsheet')
107
+ section.option('--json', 'Raw JSON output')
108
+ end
109
+
86
110
  def add_examples_section(help)
87
111
  help.section('EXAMPLES') do |s|
88
112
  s.example('slk deactivations', 'Most recent departures')
89
113
  s.example('slk deactivations 90d', 'Everyone who left in the last 90 days')
90
114
  s.example('slk deactivations --chart', 'Departures per month')
115
+ s.example('slk deactivations --tenure', 'How long each person stayed')
116
+ s.example('slk deactivations 1y --csv > left.csv', 'Export a year of departures')
91
117
  s.example('slk deactivations 2026-01-01 -n 0', 'All departures this year')
92
118
  end
93
119
  end
94
120
 
95
121
  def run
96
122
  workspace = runner.workspace(@options[:workspace])
123
+ validate_combination
97
124
  @since_label = since_spec
98
125
  @since = parse_since(@since_label)
99
126
  report = scan(workspace)
100
127
  records = collect_records(report)
101
128
 
129
+ emit(workspace, report, records)
130
+ 0
131
+ end
132
+
133
+ # --csv and --json export every match; the terminal list is the only
134
+ # view that pages, so it is the only one -n applies to. (--chart spans
135
+ # its whole window too, for the same reason.)
136
+ def emit(workspace, report, records)
137
+ return render_csv(workspace, records) if @options[:csv]
102
138
  return render_json(workspace, report, records) if @options[:json]
103
139
 
104
140
  render(workspace, report, records)
105
- 0
106
141
  end
107
142
 
108
143
  # One window, or none. A second date is a different question, and
@@ -114,16 +149,98 @@ module Slk
114
149
  @options[:since] || positional_args.first
115
150
  end
116
151
 
152
+ # A histogram counts departures per month; it has no row to hang a
153
+ # tenure on. Refusing beats quietly ignoring the flag someone paid
154
+ # attention to type.
155
+ # Two ways of asking for the same rows is one too many, and picking a
156
+ # winner silently means the other flag looks broken.
157
+ def validate_combination
158
+ raise UsageError, '--tenure has nothing to add to --chart; drop one of them.' if
159
+ @options[:tenure] && @options[:chart]
160
+ raise UsageError, '--csv and --json are two different exports; pick one.' if
161
+ @options[:csv] && @options[:json]
162
+ end
163
+
117
164
  def collect_records(report)
118
165
  records = filter(report.records)
119
166
  @options[:chart] && @since.nil? ? last_year(records) : records
120
167
  end
121
168
 
122
169
  def render_json(workspace, report, records)
123
- output_json(json_payload(workspace, report, records))
170
+ output_json(json_payload(workspace, report, records, tenures(workspace, records)))
124
171
  0
125
172
  end
126
173
 
174
+ def render_csv(workspace, records)
175
+ Formatters::DeactivationCsv.new(
176
+ output: output, tenures: @options[:tenure] ? tenures(workspace, records) : nil
177
+ ).render(records)
178
+ 0
179
+ end
180
+
181
+ # Start dates cost one rate-limited call each, so they are only ever
182
+ # fetched for rows that will actually be shown. The caller has already
183
+ # applied -n (or deliberately not, for an export); this memo assumes one
184
+ # record set per run, which is what a single command does.
185
+ def tenures(workspace, records)
186
+ return {} unless @options[:tenure]
187
+
188
+ @tenures ||= resolve_tenures(workspace, records)
189
+ end
190
+
191
+ def resolve_tenures(workspace, records)
192
+ lookup = start_date_lookup(workspace)
193
+ announce_cost(lookup, records)
194
+ dates = begin
195
+ lookup.fetch(records.map(&:user_id))
196
+ ensure
197
+ # Even when the lookup raises: otherwise the error message arrives
198
+ # glued to a half-drawn "start dates: 12/40".
199
+ output.clear_progress
200
+ end
201
+ report_cache_error(lookup)
202
+ records.to_h { |r| [r.user_id, Models::Tenure.build(dates[r.user_id], r.deactivated_time)] }
203
+ end
204
+
205
+ # The answers still arrived; they just will not be there next time.
206
+ def report_cache_error(lookup)
207
+ return unless lookup.cache_error
208
+
209
+ warn("Could not save the start date cache (#{lookup.cache_error}). " \
210
+ 'These lookups will have to be repeated next run.')
211
+ end
212
+
213
+ def start_date_lookup(workspace)
214
+ Services::StartDateLookup.new(
215
+ users_api: runner.users_api(workspace.name),
216
+ field: start_date_field(workspace),
217
+ workspace_name: workspace.name,
218
+ cache_store: cache_store,
219
+ on_progress: ->(done, total) { output.progress("start dates: #{done}/#{total}") }
220
+ )
221
+ end
222
+
223
+ def start_date_field(workspace)
224
+ Services::StartDateField.new(
225
+ team_api: runner.team_api(workspace.name),
226
+ workspace_name: workspace.name,
227
+ cache_store: cache_store,
228
+ on_debug: ->(msg) { output.debug(msg) }
229
+ )
230
+ end
231
+
232
+ # Better to say how long this will take than to let someone wonder
233
+ # whether the terminal has hung.
234
+ def announce_cost(lookup, records)
235
+ pending = lookup.uncached_count(records.map(&:user_id))
236
+ return if pending < COST_WARNING_AT
237
+
238
+ minutes = [(pending.to_f / LOOKUPS_PER_MINUTE).round, 1].max
239
+ warn("Looking up #{pending} start dates, one profile call each. Slack rate-limits these " \
240
+ "to about #{LOOKUPS_PER_MINUTE} a minute, so this will take roughly #{minutes} " \
241
+ "minute#{'s' if minutes > 1}. Interrupting is safe: each answer is cached as it arrives.")
242
+ end
243
+
127
244
  def scan(workspace)
128
245
  Services::DeactivationScanner.new(
129
246
  users_api: runner.users_api(workspace.name),
@@ -192,7 +309,7 @@ module Slk
192
309
  def render(workspace, report, records)
193
310
  formatter = Formatters::DeactivationFormatter.new(output: output, width: @options[:width])
194
311
  formatter.summary(summary_line(workspace, report, records))
195
- render_body(formatter, records)
312
+ render_body(formatter, workspace, records)
196
313
  footer = footer(report, records)
197
314
  return if footer.empty?
198
315
 
@@ -202,17 +319,19 @@ module Slk
202
319
 
203
320
  # Even an empty result keeps its footer: "nobody matched" is worth much
204
321
  # less without how old the roster behind it is.
205
- def render_body(formatter, records)
322
+ def render_body(formatter, workspace, records)
206
323
  return info('No deactivations match.') if records.empty?
207
324
 
208
325
  puts
209
- @options[:chart] ? formatter.chart(records, **chart_bounds) : render_list(formatter, records)
326
+ return formatter.chart(records, **chart_bounds) if @options[:chart]
327
+
328
+ render_list(formatter, workspace, records)
210
329
  end
211
330
 
212
- def render_list(formatter, records)
331
+ def render_list(formatter, workspace, records)
213
332
  limit = @options[:limit] || DEFAULT_LIMIT
214
333
  shown = limit.positive? ? records.first(limit) : records
215
- formatter.list(shown)
334
+ formatter.list(shown, tenures: tenures(workspace, shown))
216
335
  return unless shown.size < records.size
217
336
 
218
337
  puts
@@ -267,7 +386,7 @@ module Slk
267
386
  Models::Duration.new(seconds: seconds).to_s
268
387
  end
269
388
 
270
- def json_payload(workspace, report, records)
389
+ def json_payload(workspace, report, records, tenures = {})
271
390
  {
272
391
  workspace: workspace.name,
273
392
  fetched_at: report.fetched_at,
@@ -276,9 +395,20 @@ module Slk
276
395
  total_deactivated: total_deactivated(report),
277
396
  includes_bots: @options[:bots] ? true : false,
278
397
  matched: records.size,
279
- deactivations: records.map { |r| r.to_h.merge(deactivated_on: r.date) }
398
+ deactivations: records.map { |r| json_entry(r, tenures) }
280
399
  }
281
400
  end
401
+
402
+ # started_on and tenure_months appear only when they were asked for:
403
+ # a null that means "not looked up" is indistinguishable from one that
404
+ # means "nobody filled it in".
405
+ def json_entry(record, tenures)
406
+ entry = record.to_h.merge(deactivated_on: record.date)
407
+ return entry unless @options[:tenure]
408
+
409
+ tenure = tenures[record.user_id]
410
+ entry.merge(started_on: tenure&.started, tenure_months: tenure&.months)
411
+ end
282
412
  end
283
413
  # rubocop:enable Metrics/ClassLength
284
414
  end
@@ -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