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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Start dates live in a workspace custom profile field, which `users.list`
6
+ # does not return — they cost one `users.profile.get` per person, and Slack
7
+ # rate-limits that endpoint hard enough that a screenful takes minutes
8
+ # rather than seconds.
9
+ #
10
+ # So every answer is cached, and cached the moment it arrives rather than
11
+ # at the end: interrupting a long lookup keeps the work already paid for.
12
+ # Accounts with no start date on file are cached too, otherwise every run
13
+ # would pay again to learn the same nothing.
14
+ #
15
+ # The cache holds for 30 days. A departed account's start date rarely
16
+ # changes, but an admin correcting a typo in one is exactly the case the
17
+ # expiry exists for; `slk cache clear` forces the issue sooner.
18
+ class StartDateLookup
19
+ CACHE_KEY = 'start_dates_v1'
20
+ TTL = 2_592_000 # 30 days
21
+
22
+ class MissingFieldError < Slk::Error; end
23
+
24
+ # Set when the cache could not be written. The lookup carries on — the
25
+ # cache is an optimisation, and the answers are worth more than it.
26
+ attr_reader :cache_error
27
+
28
+ def initialize(users_api:, field:, workspace_name:, cache_store: nil, on_progress: nil)
29
+ @users_api = users_api
30
+ @field = field
31
+ @workspace_name = workspace_name
32
+ @cache = cache_store
33
+ @on_progress = on_progress
34
+ end
35
+
36
+ # @return [Hash{String => String, nil}] user ID => ISO date, or nil for
37
+ # an account with no start date recorded
38
+ def fetch(user_ids)
39
+ # Checked even for an empty list: "this workspace cannot do tenure" is
40
+ # the same fact whether or not anyone matched the filter, and finding
41
+ # out only when someone matches makes it look intermittent.
42
+ raise MissingFieldError, @field.missing_message if @field.id.to_s.empty?
43
+ return {} if user_ids.empty?
44
+
45
+ known = cached
46
+ user_ids.each_with_index { |id, index| resolve(known, id, index, user_ids.size) }
47
+ known.slice(*user_ids)
48
+ end
49
+
50
+ # How many of these would cost an API call, so a caller can warn about
51
+ # the wait before making someone sit through it.
52
+ def uncached_count(user_ids)
53
+ known = cached
54
+ user_ids.count { |id| !known.key?(id) }
55
+ end
56
+
57
+ private
58
+
59
+ def resolve(known, user_id, index, total)
60
+ return if known.key?(user_id)
61
+
62
+ @on_progress&.call(index + 1, total)
63
+ known[user_id] = start_date_for(user_id)
64
+ remember(known)
65
+ end
66
+
67
+ # A full or read-only disk must not throw away a lookup that cost
68
+ # minutes of rate-limited calls. MetaCache.write hands back the failure
69
+ # instead of raising; the caller reports it once at the end.
70
+ def remember(known)
71
+ failure = MetaCache.write(@cache, @workspace_name, CACHE_KEY, known)
72
+ # First failure only: the same unwritable disk will fail every row.
73
+ @cache_error = failure.message if failure && @cache_error.nil?
74
+ end
75
+
76
+ # Which account failed matters when the answer arrives twenty rows into
77
+ # a run that has already taken three minutes.
78
+ def start_date_for(user_id)
79
+ fields = @users_api.profile_for(user_id).dig('profile', 'fields')
80
+ value = fields.is_a?(Hash) ? fields.dig(@field.id, 'value') : nil
81
+ value.to_s.empty? ? nil : value
82
+ rescue ApiError => e
83
+ # Only the plain kind: re-wrapping a RateLimitError would drop its
84
+ # retry_after and the class the retry logic looks for.
85
+ raise unless e.instance_of?(ApiError)
86
+
87
+ raise ApiError.new("#{e.message} (looking up #{user_id})", code: e.code)
88
+ end
89
+
90
+ def cached
91
+ @cached ||= begin
92
+ data = MetaCache.read(@cache, @workspace_name, CACHE_KEY, ttl: TTL)
93
+ data.is_a?(Hash) ? data : {}
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ module Slk
8
+ module Support
9
+ # A stateless, exact timestamp for sent-conversation change detection.
10
+ class CheckInTime
11
+ RELATIVE = /\A(\d+)([mhd])\z/i
12
+ CLOCK = /\A(\d{1,2}):(\d{2})\z/
13
+ EPOCH = /\A\d{9,12}(?:\.\d{1,6})?\z/
14
+ ISO = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\z/
15
+
16
+ def self.parse(value, now: Time.now)
17
+ time = case value
18
+ when RELATIVE then relative(Regexp.last_match, now)
19
+ when CLOCK then clock(Regexp.last_match, now)
20
+ when EPOCH then epoch_time(value)
21
+ when ISO then iso_time(value)
22
+ else raise UsageError, 'Invalid --changed-since time. Use H:MM or HH:MM, ISO, epoch, 90m, 2h, or 1d.'
23
+ end
24
+ raise UsageError, '--changed-since must not be in the future.' if time > now
25
+
26
+ time
27
+ end
28
+
29
+ def self.timestamp(time)
30
+ format('%<seconds>d.%<micros>06d', seconds: time.to_i, micros: time.usec)
31
+ end
32
+
33
+ def self.epoch_time(value)
34
+ Time.at(BigDecimal(value).to_r)
35
+ rescue ArgumentError
36
+ raise UsageError, "Invalid --changed-since time: #{value.inspect}."
37
+ end
38
+
39
+ def self.iso_time(value)
40
+ Date.iso8601(value[0, 10])
41
+ with_seconds = value.sub(/(T\d{2}:\d{2})(?=Z|[+-]\d{2}:\d{2}|\z)/, '\\1:00')
42
+ Time.iso8601(with_seconds)
43
+ rescue ArgumentError
44
+ raise UsageError, "Invalid --changed-since time: #{value.inspect}."
45
+ end
46
+
47
+ def self.relative(match, now)
48
+ amount = match[1].to_i
49
+ raise UsageError, '--changed-since duration must be positive.' if amount.zero?
50
+
51
+ seconds = { 'm' => 60, 'h' => 3600, 'd' => 86_400 }.fetch(match[2].downcase)
52
+ now - (amount * seconds)
53
+ end
54
+
55
+ def self.clock(match, now)
56
+ hour, minute = match.captures.map(&:to_i)
57
+ raise UsageError, 'Invalid --changed-since clock time.' unless hour < 24 && minute < 60
58
+
59
+ day = now.to_date
60
+ time = local_clock(day, hour, minute)
61
+ return time if time <= now
62
+
63
+ local_clock(day - 1, hour, minute)
64
+ end
65
+
66
+ def self.local_clock(day, hour, minute)
67
+ Time.local(day.year, day.month, day.day, hour, minute)
68
+ rescue ArgumentError
69
+ raise UsageError, 'Invalid --changed-since clock time.'
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.9.0'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -6,6 +6,7 @@ require 'json'
6
6
  require 'fileutils'
7
7
  require 'optparse'
8
8
  require 'time'
9
+ require 'date'
9
10
  require 'io/console'
10
11
 
11
12
  # Slack CLI - A command-line interface for Slack
@@ -75,6 +76,7 @@ module Slk
75
76
  autoload :Channel, 'slk/models/channel'
76
77
  autoload :Preset, 'slk/models/preset'
77
78
  autoload :Deactivation, 'slk/models/deactivation'
79
+ autoload :Tenure, 'slk/models/tenure'
78
80
  autoload :SearchResult, 'slk/models/search_result'
79
81
  autoload :SavedItem, 'slk/models/saved_item'
80
82
  autoload :Profile, 'slk/models/profile'
@@ -109,6 +111,12 @@ module Slk
109
111
  autoload :ProfileResolver, 'slk/services/profile_resolver'
110
112
  autoload :MetaCache, 'slk/services/meta_cache'
111
113
  autoload :DeactivationScanner, 'slk/services/deactivation_scanner'
114
+ autoload :StartDateLookup, 'slk/services/start_date_lookup'
115
+ autoload :StartDateField, 'slk/services/start_date_field'
116
+ autoload :SearchPages, 'slk/services/search_pages'
117
+ autoload :SentConversations, 'slk/services/sent_conversations'
118
+ autoload :SentChanges, 'slk/services/sent_changes'
119
+ autoload :SentChannelLabel, 'slk/services/sent_channel_label'
112
120
  end
113
121
 
114
122
  # Output formatters for messages, durations, and emoji
@@ -126,12 +134,15 @@ module Slk
126
134
  autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
127
135
  autoload :BlockFormatter, 'slk/formatters/block_formatter'
128
136
  autoload :SearchFormatter, 'slk/formatters/search_formatter'
137
+ autoload :SentFormatter, 'slk/formatters/sent_formatter'
129
138
  autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
130
139
  autoload :TextProcessor, 'slk/formatters/text_processor'
131
140
  autoload :ProfileFormatter, 'slk/formatters/profile_formatter'
132
141
  autoload :ProfileFieldRenderer, 'slk/formatters/profile_field_renderer'
133
142
  autoload :ProfileRows, 'slk/formatters/profile_rows'
134
143
  autoload :DeactivationFormatter, 'slk/formatters/deactivation_formatter'
144
+ autoload :DeactivationCsv, 'slk/formatters/deactivation_csv'
145
+ autoload :CsvWriter, 'slk/formatters/csv_writer'
135
146
  end
136
147
 
137
148
  # CLI commands implementing user-facing functionality
@@ -146,6 +157,7 @@ module Slk
146
157
  autoload :Catchup, 'slk/commands/catchup'
147
158
  autoload :Activity, 'slk/commands/activity'
148
159
  autoload :Search, 'slk/commands/search'
160
+ autoload :Sent, 'slk/commands/sent'
149
161
  autoload :Preset, 'slk/commands/preset'
150
162
  autoload :Workspaces, 'slk/commands/workspaces'
151
163
  autoload :Cache, 'slk/commands/cache'
@@ -187,6 +199,7 @@ module Slk
187
199
  autoload :TextWrapper, 'slk/support/text_wrapper'
188
200
  autoload :InteractivePrompt, 'slk/support/interactive_prompt'
189
201
  autoload :DateParser, 'slk/support/date_parser'
202
+ autoload :CheckInTime, 'slk/support/check_in_time'
190
203
  autoload :TimeParser, 'slk/support/time_parser'
191
204
  autoload :TimeRangeParser, 'slk/support/time_range_parser'
192
205
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -56,6 +56,7 @@ files:
56
56
  - lib/slk/commands/presence.rb
57
57
  - lib/slk/commands/preset.rb
58
58
  - lib/slk/commands/search.rb
59
+ - lib/slk/commands/sent.rb
59
60
  - lib/slk/commands/ssh_key_manager.rb
60
61
  - lib/slk/commands/status.rb
61
62
  - lib/slk/commands/thread.rb
@@ -65,6 +66,8 @@ files:
65
66
  - lib/slk/formatters/activity_formatter.rb
66
67
  - lib/slk/formatters/attachment_formatter.rb
67
68
  - lib/slk/formatters/block_formatter.rb
69
+ - lib/slk/formatters/csv_writer.rb
70
+ - lib/slk/formatters/deactivation_csv.rb
68
71
  - lib/slk/formatters/deactivation_formatter.rb
69
72
  - lib/slk/formatters/duration_formatter.rb
70
73
  - lib/slk/formatters/emoji_replacer.rb
@@ -80,6 +83,7 @@ files:
80
83
  - lib/slk/formatters/reaction_formatter.rb
81
84
  - lib/slk/formatters/saved_item_formatter.rb
82
85
  - lib/slk/formatters/search_formatter.rb
86
+ - lib/slk/formatters/sent_formatter.rb
83
87
  - lib/slk/formatters/text_processor.rb
84
88
  - lib/slk/models/channel.rb
85
89
  - lib/slk/models/deactivation.rb
@@ -95,6 +99,7 @@ files:
95
99
  - lib/slk/models/search_result.rb
96
100
  - lib/slk/models/status.rb
97
101
  - lib/slk/models/status_snapshot.rb
102
+ - lib/slk/models/tenure.rb
98
103
  - lib/slk/models/user.rb
99
104
  - lib/slk/models/workspace.rb
100
105
  - lib/slk/runner.rb
@@ -114,7 +119,13 @@ files:
114
119
  - lib/slk/services/profile_builder.rb
115
120
  - lib/slk/services/profile_resolver.rb
116
121
  - lib/slk/services/reaction_enricher.rb
122
+ - lib/slk/services/search_pages.rb
123
+ - lib/slk/services/sent_changes.rb
124
+ - lib/slk/services/sent_channel_label.rb
125
+ - lib/slk/services/sent_conversations.rb
117
126
  - lib/slk/services/setup_wizard.rb
127
+ - lib/slk/services/start_date_field.rb
128
+ - lib/slk/services/start_date_lookup.rb
118
129
  - lib/slk/services/target_resolver.rb
119
130
  - lib/slk/services/token_loader.rb
120
131
  - lib/slk/services/token_saver.rb
@@ -124,6 +135,7 @@ files:
124
135
  - lib/slk/services/user_matcher.rb
125
136
  - lib/slk/services/user_picker.rb
126
137
  - lib/slk/services/who_target_resolver.rb
138
+ - lib/slk/support/check_in_time.rb
127
139
  - lib/slk/support/date_parser.rb
128
140
  - lib/slk/support/error_logger.rb
129
141
  - lib/slk/support/help_formatter.rb
@@ -159,7 +171,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
159
171
  - !ruby/object:Gem::Version
160
172
  version: '0'
161
173
  requirements: []
162
- rubygems_version: 4.0.16
174
+ rubygems_version: 4.0.20
163
175
  specification_version: 4
164
176
  summary: A command-line interface for Slack
165
177
  test_files: []