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,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders expanded sent-message conversations for people and scripts.
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
|
+
class SentFormatter
|
|
8
|
+
def initialize(runner:, options: {})
|
|
9
|
+
@runner = runner
|
|
10
|
+
@options = options
|
|
11
|
+
@names = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def display(conversations, start_date:, end_date:, json: false)
|
|
15
|
+
if json
|
|
16
|
+
@runner.output.puts(JSON.pretty_generate(payload(conversations, start_date, end_date)))
|
|
17
|
+
else
|
|
18
|
+
display_text(conversations)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def display_changes(changes, changed_since:, lookback_days:, json: false)
|
|
23
|
+
if json
|
|
24
|
+
@runner.output.puts(JSON.pretty_generate(changes_payload(changes, changed_since, lookback_days)))
|
|
25
|
+
else
|
|
26
|
+
display_changed_text(changes)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def payload(conversations, start_date, end_date)
|
|
33
|
+
{ date: start_date == end_date ? start_date.iso8601 : nil,
|
|
34
|
+
range: start_date == end_date ? nil : { since: start_date.iso8601, through: end_date.iso8601 },
|
|
35
|
+
conversations: conversations.map { |conversation| json_conversation(conversation) } }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def changes_payload(changes, since, days)
|
|
39
|
+
{ date: nil, range: { since: (Date.today - days + 1).iso8601, through: Date.today.iso8601 },
|
|
40
|
+
changed_since: { iso: since.iso8601(since.usec.zero? ? 0 : 6),
|
|
41
|
+
ts: Support::CheckInTime.timestamp(since) },
|
|
42
|
+
lookback_days: days, conversations: changes.map { |change| json_change(change) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def json_change(change)
|
|
46
|
+
conversation = change.conversation
|
|
47
|
+
json_conversation(conversation).merge(
|
|
48
|
+
new_count: change.new_count, new_from_others: change.new_from_others,
|
|
49
|
+
messages: conversation.messages.map do |message|
|
|
50
|
+
json_message(message, conversation).merge(new: change.new_timestamps.include?(message.ts))
|
|
51
|
+
end
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def json_conversation(conversation)
|
|
56
|
+
{ workspace: conversation.workspace.name, channel_id: conversation.channel_id,
|
|
57
|
+
channel_name: conversation.channel_name, channel_label: channel_label(conversation), type: conversation.type,
|
|
58
|
+
thread_ts: conversation.thread_ts, last_speaker_is_me: conversation.last_speaker_is_me,
|
|
59
|
+
dropped_messages: conversation.dropped_messages,
|
|
60
|
+
messages: conversation.messages.map { |message| json_message(message, conversation) } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def json_message(message, conversation)
|
|
64
|
+
{ ts: message.ts, user: message.user_id, user_name: user_name(message, conversation.workspace),
|
|
65
|
+
text: message.text, mine: mine?(message, conversation), thread_ts: message.thread_ts }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def user_name(message, workspace)
|
|
69
|
+
key = [workspace.name, message.user_id]
|
|
70
|
+
@names[key] ||= message.embedded_username || @runner.cache_store.get_user(workspace.name, message.user_id) ||
|
|
71
|
+
Services::UserLookup.new(cache_store: @runner.cache_store, workspace: workspace,
|
|
72
|
+
api_client: @runner.api_client).resolve_name_or_bot(message.user_id) ||
|
|
73
|
+
message.user_id
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def display_text(conversations)
|
|
77
|
+
return @runner.output.puts 'No sent conversations found.' if conversations.empty?
|
|
78
|
+
|
|
79
|
+
display_items(conversations) { |conversation| display_conversation(conversation) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def display_conversation(conversation)
|
|
83
|
+
display_header(conversation)
|
|
84
|
+
display_messages(conversation.messages, conversation)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def display_items(items)
|
|
88
|
+
items.each_with_index do |item, index|
|
|
89
|
+
display_divider if index.positive?
|
|
90
|
+
yield item
|
|
91
|
+
@runner.output.puts
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def display_divider
|
|
96
|
+
width = (@options[:width] || 32).clamp(1, 32)
|
|
97
|
+
@runner.output.puts @runner.output.gray('─' * width)
|
|
98
|
+
@runner.output.puts
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def display_changed_text(changes)
|
|
102
|
+
return @runner.output.puts 'No changed sent conversations found.' if changes.empty?
|
|
103
|
+
|
|
104
|
+
display_items(changes) { |change| display_changed_conversation(change) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def display_changed_conversation(change)
|
|
108
|
+
conversation = change.conversation
|
|
109
|
+
display_header(conversation, summary: "#{change.new_count} new (#{change.new_from_others} from others)")
|
|
110
|
+
context, fresh = conversation.messages.partition { |message| !change.new_timestamps.include?(message.ts) }
|
|
111
|
+
display_messages(context, conversation, context: true)
|
|
112
|
+
display_messages(fresh, conversation, parent_timestamps: context.map(&:ts))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def display_messages(messages, conversation, context: false, parent_timestamps: [])
|
|
116
|
+
replies = replies_by_parent(messages)
|
|
117
|
+
messages.each do |message|
|
|
118
|
+
next if replies.key?(message.thread_ts) && message.reply?
|
|
119
|
+
|
|
120
|
+
display_message(message, conversation,
|
|
121
|
+
orphan: message.reply? && !parent_timestamps.include?(message.thread_ts), context: context)
|
|
122
|
+
replies.fetch(message.ts, []).each { |reply| display_message(reply, conversation, context: context) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def replies_by_parent(messages)
|
|
127
|
+
timestamps = messages.to_h { |message| [message.ts, true] }
|
|
128
|
+
messages.select { |message| message.reply? && timestamps.key?(message.thread_ts) }
|
|
129
|
+
.group_by(&:thread_ts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Header combines the resolved destination and optional thread ID.
|
|
133
|
+
def display_header(conversation, summary: nil)
|
|
134
|
+
@runner.output.puts wrap_heading(heading_for(conversation, summary))
|
|
135
|
+
return unless conversation.dropped_messages.positive?
|
|
136
|
+
|
|
137
|
+
@runner.output.puts "(#{conversation.dropped_messages} older messages omitted by --max)"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def heading_for(conversation, summary)
|
|
141
|
+
heading = "[#{conversation.workspace.name}] #{channel_label(conversation)}"
|
|
142
|
+
heading += " (thread: #{conversation.thread_ts})" if conversation.type == 'thread'
|
|
143
|
+
heading += " — #{summary}" if summary
|
|
144
|
+
heading
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def display_message(message, conversation, orphan: false, context: false)
|
|
148
|
+
display_orphan_reference(message, context) if orphan
|
|
149
|
+
prefix = message.reply? ? ' ↳ ' : ''
|
|
150
|
+
prefix = "· #{prefix}" if context
|
|
151
|
+
line = "#{prefix}#{formatted_message(message, conversation, prefix)}"
|
|
152
|
+
@runner.output.puts(context ? @runner.output.gray(line) : line)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def display_orphan_reference(message, context)
|
|
156
|
+
line = " (thread #{message.thread_ts})"
|
|
157
|
+
line = "· #{line}" if context
|
|
158
|
+
@runner.output.puts(context ? @runner.output.gray(line) : line)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def formatted_message(message, conversation, prefix)
|
|
162
|
+
indent = ' ' * Support::TextWrapper.visible_length(prefix)
|
|
163
|
+
options = @options.dup
|
|
164
|
+
options[:width] -= indent.length if options[:width]
|
|
165
|
+
formatted = @runner.message_formatter.format(message, workspace: conversation.workspace, options: options)
|
|
166
|
+
indent.empty? ? formatted : formatted.gsub("\n", "\n#{indent}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def wrap_heading(heading)
|
|
170
|
+
width = @options[:width]
|
|
171
|
+
return heading unless width && width > 2
|
|
172
|
+
|
|
173
|
+
Support::TextWrapper.wrap(heading, width, width - 2).gsub("\n", "\n ")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def channel_label(conversation)
|
|
177
|
+
@runner.sent_channel_label.label(
|
|
178
|
+
workspace: conversation.workspace, type: conversation.channel_type,
|
|
179
|
+
name: conversation.channel_name, channel_id: conversation.channel_id,
|
|
180
|
+
self_user_id: conversation.self_user_id, self_username: conversation.self_username
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def mine?(message, conversation)
|
|
185
|
+
# The search result provides the authenticated sender's user ID. It
|
|
186
|
+
# remains attached to each conversation even if the first message is
|
|
187
|
+
# an older parent from someone else.
|
|
188
|
+
message.user_id == conversation.self_user_id
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
# rubocop:enable Metrics/ClassLength
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -13,6 +13,7 @@ module Slk
|
|
|
13
13
|
:channel_name,
|
|
14
14
|
:channel_type,
|
|
15
15
|
:thread_ts,
|
|
16
|
+
:reply_count,
|
|
16
17
|
:permalink,
|
|
17
18
|
:files
|
|
18
19
|
) do
|
|
@@ -32,6 +33,7 @@ module Slk
|
|
|
32
33
|
channel_name: channel['name'],
|
|
33
34
|
channel_type: determine_channel_type(channel),
|
|
34
35
|
thread_ts: extract_thread_ts(match),
|
|
36
|
+
reply_count: match['reply_count'].to_i,
|
|
35
37
|
permalink: match['permalink'],
|
|
36
38
|
files: extract_files(match)
|
|
37
39
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# How long someone was here: a start date from their Slack profile and the
|
|
6
|
+
# date their account was deactivated.
|
|
7
|
+
#
|
|
8
|
+
# Both ends are softer than they look. The start date is whatever an admin
|
|
9
|
+
# typed into a custom profile field, and the end is the account's `updated`
|
|
10
|
+
# timestamp. Neither is a payroll record, so this rounds to whole months
|
|
11
|
+
# and refuses to imply a precision it does not have.
|
|
12
|
+
Tenure = Data.define(:started_on, :ended_on) do
|
|
13
|
+
# @param started [String, nil] ISO date from the profile field
|
|
14
|
+
# @param ended [Time, nil] deactivation time
|
|
15
|
+
def self.build(started, ended)
|
|
16
|
+
date = parse_date(started)
|
|
17
|
+
return nil unless date
|
|
18
|
+
|
|
19
|
+
new(started_on: date, ended_on: ended ? to_date(ended) : nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.parse_date(value)
|
|
23
|
+
text = value.to_s.strip
|
|
24
|
+
return nil unless /\A\d{4}-\d{2}-\d{2}\z/.match?(text)
|
|
25
|
+
|
|
26
|
+
Date.iso8601(text)
|
|
27
|
+
rescue Date::Error
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.to_date(time)
|
|
32
|
+
Date.new(time.year, time.month, time.day)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Nil when the account is still active, or when the end predates the
|
|
36
|
+
# start — a start date typed in after the fact can land anywhere, and a
|
|
37
|
+
# negative tenure is a data entry error, not a fact about a person.
|
|
38
|
+
def months
|
|
39
|
+
return nil unless ended_on && ended_on >= started_on
|
|
40
|
+
|
|
41
|
+
ended_on.day < started_on.day ? month_span - 1 : month_span
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def month_span
|
|
45
|
+
((ended_on.year - started_on.year) * 12) + (ended_on.month - started_on.month)
|
|
46
|
+
end
|
|
47
|
+
private :month_span
|
|
48
|
+
|
|
49
|
+
# "6y 2mo", "11mo", "<1mo" — whole months only.
|
|
50
|
+
def to_s
|
|
51
|
+
total = months
|
|
52
|
+
return '' unless total
|
|
53
|
+
return '<1mo' if total.zero?
|
|
54
|
+
|
|
55
|
+
years, rest = total.divmod(12)
|
|
56
|
+
[years.positive? ? "#{years}y" : nil, rest.positive? ? "#{rest}mo" : nil].compact.join(' ')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True when the length cannot be worked out: an active account, or an
|
|
60
|
+
# end date that predates the start.
|
|
61
|
+
def unknown? = months.nil?
|
|
62
|
+
|
|
63
|
+
def started = started_on.iso8601
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/slk/runner.rb
CHANGED
|
@@ -232,7 +232,7 @@ module Slk
|
|
|
232
232
|
end
|
|
233
233
|
|
|
234
234
|
def parse_success_response(response)
|
|
235
|
-
result =
|
|
235
|
+
result = parse_body(response.body)
|
|
236
236
|
raise_rate_limit(response) if result['error'] == 'ratelimited'
|
|
237
237
|
unless result['ok']
|
|
238
238
|
message = result['error'] || 'Unknown error'
|
|
@@ -240,6 +240,17 @@ module Slk
|
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
result
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Every caller treats a response as a Hash and digs into it. Slack
|
|
246
|
+
# sending a bare array, string or null is not something any of them can
|
|
247
|
+
# act on, so it fails here as an API error rather than as a TypeError
|
|
248
|
+
# somewhere downstream.
|
|
249
|
+
def parse_body(body)
|
|
250
|
+
result = JSON.parse(body)
|
|
251
|
+
return result if result.is_a?(Hash)
|
|
252
|
+
|
|
253
|
+
raise ApiError.new('Unexpected response shape from Slack API', code: :invalid_response)
|
|
243
254
|
rescue JSON::ParserError
|
|
244
255
|
raise ApiError.new('Invalid JSON response from Slack API', code: :invalid_json)
|
|
245
256
|
end
|
|
@@ -135,6 +135,19 @@ module Slk
|
|
|
135
135
|
end
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# The meta cache holds everything that is neither a user nor a channel:
|
|
139
|
+
# start dates, the deactivation roster, resolved profiles. "Clear all
|
|
140
|
+
# caches" was not telling the truth while this was left behind.
|
|
141
|
+
def clear_meta_cache(workspace_name = nil)
|
|
142
|
+
if workspace_name
|
|
143
|
+
@meta_cache.delete(workspace_name)
|
|
144
|
+
FileUtils.rm_f(meta_cache_file(workspace_name))
|
|
145
|
+
else
|
|
146
|
+
@meta_cache.clear
|
|
147
|
+
Dir.glob(@paths.cache_file('meta-*.json')).each { |f| FileUtils.rm_f(f) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
138
151
|
# Subteam cache methods
|
|
139
152
|
def get_subteam(workspace_name, subteam_id)
|
|
140
153
|
load_subteam_cache(workspace_name)
|
|
@@ -199,6 +212,12 @@ module Slk
|
|
|
199
212
|
@on_warning&.call("#{cache_type} cache corrupted for #{workspace_name}: #{e.message}. Cache will be rebuilt.")
|
|
200
213
|
safely_delete_file(file)
|
|
201
214
|
{}
|
|
215
|
+
rescue SystemCallError, IOError => e
|
|
216
|
+
# An unreadable cache file is the same situation as a corrupt one:
|
|
217
|
+
# carry on without it. Not deleted, because a file we cannot read is
|
|
218
|
+
# one we probably cannot remove either, and it may not be ours.
|
|
219
|
+
@on_warning&.call("#{cache_type} cache unreadable for #{workspace_name}: #{e.message}. Continuing without it.")
|
|
220
|
+
{}
|
|
202
221
|
end
|
|
203
222
|
|
|
204
223
|
def safely_delete_file(file)
|
|
@@ -7,6 +7,8 @@ module Slk
|
|
|
7
7
|
module MetaCache
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
# A write failure here is deliberately dropped: fetch's caller wants the
|
|
11
|
+
# value, and callers that need to report a cold cache use write directly.
|
|
10
12
|
def fetch(cache_store, workspace_name, key, ttl: nil, refresh: false)
|
|
11
13
|
cached = read(cache_store, workspace_name, key, ttl: ttl) unless refresh
|
|
12
14
|
return cached if cached
|
|
@@ -22,10 +24,23 @@ module Slk
|
|
|
22
24
|
cache_store.get_meta(workspace_name, key, ttl: ttl)
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# A cache write that fails must never cost the caller the work it just
|
|
28
|
+
# did — a full or read-only disk means "no cache", not "no answer", and
|
|
29
|
+
# some of these writes sit behind minutes of rate-limited API calls.
|
|
30
|
+
#
|
|
31
|
+
# A nil or false value is treated as nothing to store, since no caller
|
|
32
|
+
# caches a negative this way — the start date lookup caches per-user
|
|
33
|
+
# nils inside a Hash, which is a value like any other.
|
|
34
|
+
#
|
|
35
|
+
# @return [Exception, nil] the write failure, for callers that want to
|
|
36
|
+
# mention it; nil when the write succeeded or there was nothing to do
|
|
25
37
|
def write(cache_store, workspace_name, key, value)
|
|
26
|
-
return unless cache_store && workspace_name && value
|
|
38
|
+
return nil unless cache_store && workspace_name && value
|
|
27
39
|
|
|
28
40
|
cache_store.set_meta(workspace_name, key, value)
|
|
41
|
+
nil
|
|
42
|
+
rescue SystemCallError, IOError => e
|
|
43
|
+
e
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Walks search.messages pages without silently dropping results at Slack's
|
|
6
|
+
# 100-result page boundary. A limit caps results per workspace; nil fetches all.
|
|
7
|
+
class SearchPages
|
|
8
|
+
def initialize(search_api)
|
|
9
|
+
@search_api = search_api
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The loop tracks both the API page and the requested result cap.
|
|
13
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
14
|
+
def fetch(query:, limit: nil, page: 1, sort_dir: 'desc')
|
|
15
|
+
results = []
|
|
16
|
+
first_pagination = nil
|
|
17
|
+
matches = []
|
|
18
|
+
current = page
|
|
19
|
+
count = limit ? [limit, 100].min : 100
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
# Keep count fixed: Slack computes page offsets from count. Changing
|
|
23
|
+
# 100 to 50 for the last page would repeat rows from the first page.
|
|
24
|
+
response = @search_api.messages(query: query, count: count, page: current, sort_dir: sort_dir)
|
|
25
|
+
messages = response.fetch('messages', {})
|
|
26
|
+
matches = messages.fetch('matches', [])
|
|
27
|
+
pagination = messages.fetch('pagination', {})
|
|
28
|
+
first_pagination ||= pagination
|
|
29
|
+
remaining = limit ? limit - results.length : matches.length
|
|
30
|
+
results.concat(matches.first(remaining).map { |match| Models::SearchResult.from_api(match) })
|
|
31
|
+
break if matches.empty?
|
|
32
|
+
|
|
33
|
+
if !pagination['page_count'] && matches.length >= count
|
|
34
|
+
raise ApiError, 'Search response omitted pagination; cannot guarantee complete results'
|
|
35
|
+
end
|
|
36
|
+
break if limit && results.length >= limit
|
|
37
|
+
break unless pagination['page_count'] && current < pagination['page_count'].to_i
|
|
38
|
+
|
|
39
|
+
current += 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
{ results: results, pagination: first_pagination || {},
|
|
43
|
+
truncated: truncated?(first_pagination, page, current, results, matches, limit) }
|
|
44
|
+
end
|
|
45
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# A short limit can cut off a page even when Slack omits total_count.
|
|
50
|
+
# The page offset and last batch are needed when total_count is missing.
|
|
51
|
+
# rubocop:disable Metrics/ParameterLists
|
|
52
|
+
def truncated?(pagination, page, current, results, matches, limit)
|
|
53
|
+
return false unless limit
|
|
54
|
+
|
|
55
|
+
offset = (page - 1) * [limit, 100].min
|
|
56
|
+
total = pagination['total_count']
|
|
57
|
+
return total.to_i > offset + results.length if total
|
|
58
|
+
|
|
59
|
+
matches.length > [limit - ((current - page) * [limit, 100].min), 0].max ||
|
|
60
|
+
pagination['page_count'].to_i > current
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/ParameterLists
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|