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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +37 -0
- data/lib/slk/api/conversations.rb +6 -2
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/cache.rb +10 -9
- data/lib/slk/commands/deactivations.rb +144 -14
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/search.rb +55 -48
- data/lib/slk/commands/sent.rb +288 -0
- data/lib/slk/formatters/attachment_formatter.rb +7 -3
- data/lib/slk/formatters/csv_writer.rb +34 -0
- data/lib/slk/formatters/deactivation_csv.rb +49 -0
- data/lib/slk/formatters/deactivation_formatter.rb +27 -11
- data/lib/slk/formatters/output.rb +39 -0
- data/lib/slk/formatters/search_formatter.rb +25 -11
- data/lib/slk/formatters/sent_formatter.rb +193 -0
- data/lib/slk/models/search_result.rb +2 -0
- data/lib/slk/models/tenure.rb +66 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/api_client.rb +12 -1
- data/lib/slk/services/cache_store.rb +19 -0
- data/lib/slk/services/meta_cache.rb +16 -1
- data/lib/slk/services/search_pages.rb +65 -0
- data/lib/slk/services/sent_changes.rb +293 -0
- data/lib/slk/services/sent_channel_label.rb +66 -0
- data/lib/slk/services/sent_conversations.rb +227 -0
- data/lib/slk/services/start_date_field.rb +67 -0
- data/lib/slk/services/start_date_lookup.rb +98 -0
- data/lib/slk/support/check_in_time.rb +73 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +13 -0
- metadata +14 -2
|
@@ -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
|
|
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
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# RFC 4180 CSV, hand-rolled: Ruby 3.4 moved csv out of the default gems
|
|
6
|
+
# and into the bundled ones, so requiring it would make this tool depend
|
|
7
|
+
# on a gem, and it ships with none.
|
|
8
|
+
#
|
|
9
|
+
# Quotes only the fields that need it, so the common row stays readable in
|
|
10
|
+
# a terminal as well as in a spreadsheet.
|
|
11
|
+
module CsvWriter
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
NEEDS_QUOTES = /[",\r\n]|\A\s|\s\z/
|
|
15
|
+
|
|
16
|
+
def row(values)
|
|
17
|
+
values.map { |value| escape(value) }.join(',')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def escape(value)
|
|
21
|
+
text = stringify(value)
|
|
22
|
+
return text unless NEEDS_QUOTES.match?(text)
|
|
23
|
+
|
|
24
|
+
%("#{text.gsub('"', '""')}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# nil is an empty cell. Writing the literal "nil" would give a
|
|
28
|
+
# spreadsheet a four-character string to count, sort and average.
|
|
29
|
+
def stringify(value)
|
|
30
|
+
value.nil? ? '' : value.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# CSV export of a deactivation list, for the spreadsheet that inevitably
|
|
6
|
+
# gets asked for. Every matched row is written, not just the screenful a
|
|
7
|
+
# terminal would show — a truncated export is a wrong answer that looks
|
|
8
|
+
# like a right one.
|
|
9
|
+
class DeactivationCsv
|
|
10
|
+
HEADERS = %w[deactivated_on user_id handle real_name title email bot].freeze
|
|
11
|
+
TENURE_HEADERS = %w[started_on tenure_months tenure].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(output:, tenures: nil)
|
|
14
|
+
@output = output
|
|
15
|
+
@tenures = tenures
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render(records)
|
|
19
|
+
@output.puts(CsvWriter.row(headers))
|
|
20
|
+
records.each { |record| @output.puts(CsvWriter.row(cells(record))) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def headers
|
|
26
|
+
@tenures ? HEADERS + TENURE_HEADERS : HEADERS
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cells(record)
|
|
30
|
+
row = [record.date, record.user_id, record.handle, record.real_name,
|
|
31
|
+
record.title, record.email, record.bot]
|
|
32
|
+
@tenures ? row + tenure_cells(record) : row
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Empty cells rather than zeros, so a spreadsheet averaging tenure skips
|
|
36
|
+
# them instead of counting people who left the day they arrived. Two
|
|
37
|
+
# different unknowns land here: no start date on file at all, which
|
|
38
|
+
# blanks the whole group, and a start date later than the departure,
|
|
39
|
+
# which keeps started_on so the bad data is visible and blanks the
|
|
40
|
+
# length that cannot be derived from it.
|
|
41
|
+
def tenure_cells(record)
|
|
42
|
+
tenure = @tenures[record.user_id]
|
|
43
|
+
return [nil, nil, nil] unless tenure
|
|
44
|
+
|
|
45
|
+
[tenure.started, tenure.months, tenure.to_s]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -15,11 +15,16 @@ module Slk
|
|
|
15
15
|
@width = width || 100
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# One line per departure: date, name, title. Full ISO dates on
|
|
19
|
-
# (rather than month headings) so the output stays greppable.
|
|
20
|
-
|
|
18
|
+
# One line per departure: date, name, tenure, title. Full ISO dates on
|
|
19
|
+
# every row (rather than month headings) so the output stays greppable.
|
|
20
|
+
#
|
|
21
|
+
# `tenures` is keyed by user ID and may cover only some of the rows: a
|
|
22
|
+
# start date nobody filled in leaves the column blank rather than
|
|
23
|
+
# guessing, and the column disappears entirely when none are known.
|
|
24
|
+
def list(records, tenures: {})
|
|
21
25
|
name_width = name_column_width(records)
|
|
22
|
-
|
|
26
|
+
tenure_width = tenure_column_width(records, tenures)
|
|
27
|
+
records.each { |record| @output.puts(row(record, name_width, tenures, tenure_width)) }
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def chart(records, from: nil, to: nil)
|
|
@@ -61,21 +66,32 @@ module Slk
|
|
|
61
66
|
"#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
|
|
62
67
|
end
|
|
63
68
|
|
|
64
|
-
def row(record, name_width)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
def row(record, name_width, tenures = {}, tenure_width = 0)
|
|
70
|
+
line = "#{@output.gray((record.date || 'unknown').ljust(DATE_WIDTH))} " \
|
|
71
|
+
"#{truncate(record.best_name.to_s, name_width).ljust(name_width)}"
|
|
72
|
+
line = "#{line} #{@output.gray(tenure_cell(record, tenures, tenure_width))}" if tenure_width.positive?
|
|
73
|
+
title = truncate(record.title.to_s, title_width(name_width, tenure_width))
|
|
69
74
|
title.empty? ? line : "#{line} #{@output.gray(title)}"
|
|
70
75
|
end
|
|
71
76
|
|
|
77
|
+
def tenure_cell(record, tenures, width)
|
|
78
|
+
tenures[record.user_id].to_s.rjust(width)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tenure_column_width(records, tenures)
|
|
82
|
+
return 0 if tenures.nil? || tenures.empty?
|
|
83
|
+
|
|
84
|
+
records.filter_map { |r| tenures[r.user_id]&.to_s&.length }.max || 0
|
|
85
|
+
end
|
|
86
|
+
|
|
72
87
|
def name_column_width(records)
|
|
73
88
|
longest = records.map { |r| r.best_name.to_s.length }.max || 0
|
|
74
89
|
longest.clamp(8, MAX_NAME_WIDTH)
|
|
75
90
|
end
|
|
76
91
|
|
|
77
|
-
def title_width(name_width)
|
|
78
|
-
|
|
92
|
+
def title_width(name_width, tenure_width = 0)
|
|
93
|
+
tenure_space = tenure_width.positive? ? tenure_width + 2 : 0
|
|
94
|
+
[@width - DATE_WIDTH - name_width - tenure_space - 4, MIN_TITLE_WIDTH].max
|
|
79
95
|
end
|
|
80
96
|
|
|
81
97
|
# A month nobody left gets no bar at all. Rounding a zero up to one
|
|
@@ -26,6 +26,7 @@ module Slk
|
|
|
26
26
|
@color = color.nil? ? io.tty? : color
|
|
27
27
|
@verbose = verbose
|
|
28
28
|
@quiet = quiet
|
|
29
|
+
@last_progress_width = nil
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def puts(message = '')
|
|
@@ -52,6 +53,44 @@ module Slk
|
|
|
52
53
|
puts(colorize(message))
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
# Transient progress on stderr: it never pollutes piped stdout, and it
|
|
57
|
+
# overwrites itself rather than scrolling. Silent under --quiet, and
|
|
58
|
+
# when stderr is not a terminal, since a log file full of half-drawn
|
|
59
|
+
# counters helps nobody.
|
|
60
|
+
def progress(message)
|
|
61
|
+
return unless progress?
|
|
62
|
+
|
|
63
|
+
@last_progress_width = message.length
|
|
64
|
+
write_progress("\r#{message}")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Erases whatever progress() last drew. Keyed off the saved width rather
|
|
68
|
+
# than re-checking tty state: if a line was drawn, it gets cleaned up.
|
|
69
|
+
def clear_progress
|
|
70
|
+
return unless @last_progress_width
|
|
71
|
+
|
|
72
|
+
write_progress("\r#{' ' * @last_progress_width}\r")
|
|
73
|
+
@last_progress_width = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# This is decoration. It is often called from an ensure block cleaning
|
|
77
|
+
# up after a real failure, and a closed or broken stderr must not
|
|
78
|
+
# replace that failure with one about drawing a counter.
|
|
79
|
+
def write_progress(text)
|
|
80
|
+
@err.print(text)
|
|
81
|
+
@err.flush
|
|
82
|
+
rescue SystemCallError, IOError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def progress? = tty_err? && !@quiet
|
|
87
|
+
|
|
88
|
+
def tty_err?
|
|
89
|
+
@err.tty?
|
|
90
|
+
rescue SystemCallError, IOError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
55
94
|
def debug(message)
|
|
56
95
|
return unless @verbose
|
|
57
96
|
|
|
@@ -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(
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
if
|
|
38
|
-
|
|
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
|
-
"##{
|
|
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
|
-
|
|
75
|
+
name = prepare_text(file[:name], workspace, options)
|
|
76
|
+
@output.puts @output.blue("[Image: #{name}]")
|
|
63
77
|
end
|
|
64
78
|
end
|
|
65
79
|
|