slk 0.7.0 → 0.8.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: 895e4fdd405a7b4f560424a93d4906ea7330088763a5559b2d581788908794b3
4
- data.tar.gz: 6f8c66d9cd6555153db454d99d7cafdf4e82bbaac2f6349edee1427047e31104
3
+ metadata.gz: ae44e63120d4264c9adeb4adb2ea9aba96c4fe9e6087c1134f9bd9d8480a843e
4
+ data.tar.gz: '087ea385b7cf62868737d1e583e60ec9fac5aee8bb3d49f711f5c979413db4e7'
5
5
  SHA512:
6
- metadata.gz: 7394318fac53168225d5ff30a249612a0f756fe60eaca67141ca937d43f8c70aaa7a1066681bb35a97fc49f242254d87438f369b02700193cc86456ded97ca9c
7
- data.tar.gz: ea890ffc51e6540c6a19db3481d92b8f0263c042594f74c4ec2ac3f975c7f988d345f69e0a6b37345993b499535180b87456cd189dffa9506b2c60148f2b9481
6
+ metadata.gz: 71be8f27208e35f5dfe81c3504f38f40af7a7630d93802ba56316e07778aa631f7a42ffe1172b9b1c280735871c0667b5b6568a3bb0224642cdabf59523ac8c5
7
+ data.tar.gz: 9e4b991d52004df5100fe901e87c1b61f8c50a8fa501665f2cb0a5f58f31a37a6ffae6cbe5c287aac7ae2e99f679cea0889c80866f7980b7f495c7311a507c73
data/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ 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
+ ## [0.8.0] - 2026-08-30
9
+
10
+ ### Added
11
+
12
+ - **`slk status` now answers the questions the status raises** — who can reach you, and whether the status is about to change on its own
13
+ - Presence and DND appear as suffixes on the status line: `:computer: Working [away] [dnd until 3:00pm]`. Only exceptional states are labelled, so the workspace that differs stands out rather than being buried under "active" on every line
14
+ - `[dnd until …]` covers a manual snooze and the configured DND hours alike — from the outside both mean messages do not notify — and reports the later end when both apply. `slk dnd` still breaks out which is which
15
+ - Anything scheduled to turn on later is listed under the status, soonest first and without IDs; `slk status scheduled` remains the view to paste an ID from
16
+ - Times carry a day when the end is not today, so an overnight window does not read as a time that has already passed
17
+ - **`slk status --json`** (and `slk status scheduled --json`) for scripts and statuslines
18
+ - Always an array, one entry per workspace, even for a single workspace: a document whose shape changes with `-w` cannot be parsed by a script that did not pass it
19
+ - `null` means "not checked" — skipped by a flag, or a lookup that failed — as distinct from checked-and-empty. A statusline that read a failed DND lookup as "DND off" would report the opposite of the truth
20
+ - Every timestamp appears twice: Slack's own epoch under Slack's own field name, and an ISO 8601 string beside it
21
+ - **`--brief` and `--no-scheduled`** for the get view. It now costs up to four calls per workspace; `--no-scheduled` drops the internal `users.customStatus.list` lookup and `--brief` drops all three extras. `--quiet` skips them too, since their output would be discarded (`--json` still gathers them: it prints regardless)
22
+
23
+ ### Changed
24
+
25
+ - A failed presence, DND or schedule lookup during `slk status` warns and omits that part instead of failing the command — the status itself is what was asked for. A rate limit stops the remaining extra lookups altogether rather than spending calls the status reads need
26
+
8
27
  ## [0.7.0] - 2026-08-03
9
28
 
10
29
  ### Added
@@ -210,6 +229,7 @@ Initial release of the Ruby rewrite. Pure Ruby, no external dependencies.
210
229
  - Pure Ruby stdlib - no gem dependencies
211
230
  - Ruby 3.2+ with modern features (Data.define, pattern matching)
212
231
 
232
+ [0.8.0]: https://github.com/ericboehs/slk/releases/tag/v0.8.0
213
233
  [0.7.0]: https://github.com/ericboehs/slk/releases/tag/v0.7.0
214
234
  [0.6.0]: https://github.com/ericboehs/slk/releases/tag/v0.6.0
215
235
  [0.5.0]: https://github.com/ericboehs/slk/releases/tag/v0.5.0
data/README.md CHANGED
@@ -46,12 +46,52 @@ You'll need a Slack token. Get one from:
46
46
  ### Status
47
47
 
48
48
  ```bash
49
- slk status # Show current status
49
+ slk status # Status, presence, DND and what's queued next
50
+ slk status --brief # Status text only (one call per workspace)
51
+ slk status --json # Machine-readable, for scripts and statuslines
50
52
  slk status "Working from home" :house: # Set status with emoji
51
53
  slk status "In a meeting" :calendar: 1h # Set status for 1 hour
52
54
  slk status clear # Clear status
53
55
  ```
54
56
 
57
+ Getting the status also answers the two questions it usually raises — can anyone
58
+ reach me, and is this about to change on its own:
59
+
60
+ ```
61
+ $ slk status
62
+ work
63
+ :computer: Working [away] [dnd until 3:00pm]
64
+ Scheduled:
65
+ :paw_prints: Vet Appt (Mon Aug 3 1:30pm -> 3:30pm)
66
+ side-project
67
+ (no status set)
68
+ ```
69
+
70
+ Only the exceptional states are labelled: an active, notifiable workspace says
71
+ nothing, so the one that differs stands out. `[dnd until ...]` covers both a
72
+ manual snooze and the configured DND hours — from the outside they are the same
73
+ thing — and `slk dnd` breaks out which.
74
+
75
+ Each of those is a separate call, so `slk status` costs up to four per
76
+ workspace. `--no-scheduled` drops the schedule lookup (the one internal
77
+ endpoint), `--brief` drops all three, and a lookup that fails warns and leaves
78
+ that part out rather than failing the command.
79
+
80
+ #### JSON output
81
+
82
+ `slk status --json` (and `slk status scheduled --json`) prints one entry per
83
+ workspace — always an array, even for a single workspace, so a script that did
84
+ not pass `-w` can still parse it. `null` means *not checked* (skipped by a flag,
85
+ or the lookup failed), which is not the same as checked-and-empty: a statusline
86
+ that read a failed DND lookup as "DND off" would say the opposite of the truth.
87
+ Every timestamp appears twice, as Slack's epoch and as ISO 8601:
88
+
89
+ ```bash
90
+ # "away, quiet until 3:00pm"
91
+ slk status -w work --json --no-scheduled |
92
+ jq -r '.[0] | "\(.presence.presence)\(if .dnd.active then ", quiet until " + (.dnd.until | strflocaltime("%-I:%M%p")) else "" end)"'
93
+ ```
94
+
55
95
  Statuses can also be scheduled to turn on later (Slack allows up to 5 at a time):
56
96
 
57
97
  ```bash
@@ -38,24 +38,39 @@ module Slk
38
38
  protected
39
39
 
40
40
  def default_options
41
- super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil)
41
+ super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil,
42
+ scheduled: true, brief: false)
42
43
  end
43
44
 
45
+ # Flags that only flip a boolean live here rather than in the case below,
46
+ # which is already at the branch count the linter allows.
47
+ TOGGLES = { '--with-dnd' => [:with_dnd, true], '--brief' => [:brief, true],
48
+ '--no-scheduled' => [:scheduled, false] }.freeze
49
+
44
50
  def handle_option(arg, args, remaining)
51
+ return toggle(arg) if TOGGLES.key?(arg)
52
+
45
53
  case arg
46
54
  when '-p', '--presence' then @options[:presence] = option_value(arg, args)
47
55
  when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
48
- when '--with-dnd' then @options[:with_dnd] = true
49
56
  when '--start' then @options[:start_at] = option_value(arg, args)
50
57
  when '--end' then @options[:end_at] = option_value(arg, args)
51
58
  else super
52
59
  end
53
60
  end
54
61
 
62
+ # True means "consumed", the contract Base#handle_option expects back.
63
+ def toggle(arg) # rubocop:disable Naming/PredicateMethod
64
+ key, value = TOGGLES[arg]
65
+ @options[key] = value
66
+ true
67
+ end
68
+
55
69
  def help_text
56
70
  help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
57
71
  help.description('Get or set your Slack status.')
58
- help.note('GET shows all workspaces by default. SET applies to primary only.')
72
+ help.note('GET shows status, presence, DND and anything queued to turn on later.')
73
+ help.note('GET covers all workspaces by default; SET applies to primary only.')
59
74
  help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
60
75
  help.note('Slack allows at most 5 scheduled statuses at a time.')
61
76
  add_examples_section(help)
@@ -66,7 +81,9 @@ module Slk
66
81
 
67
82
  def add_examples_section(help)
68
83
  help.section('EXAMPLES') do |s|
69
- s.example('slk status', 'Show status (all workspaces)')
84
+ s.example('slk status', 'Show status, presence, DND and schedule (all workspaces)')
85
+ s.example('slk status --brief', 'Status text only, no extra lookups')
86
+ s.example('slk status --json', 'Machine-readable; null means "not checked"')
70
87
  s.example('slk status clear', 'Clear status')
71
88
  s.example('slk status "Working" :laptop:', 'Set status with emoji')
72
89
  s.example('slk status "Meeting" :calendar: 1h', 'Set status for 1 hour')
@@ -89,6 +106,7 @@ module Slk
89
106
  def add_options_section(help)
90
107
  help.section('OPTIONS') do |s|
91
108
  add_general_options(s)
109
+ add_getting_options(s)
92
110
  add_scheduling_options(s)
93
111
  end
94
112
  end
@@ -102,6 +120,14 @@ module Slk
102
120
  section.option('-q, --quiet', 'Suppress output')
103
121
  end
104
122
 
123
+ # These are ignored outside a plain `slk status` (and `scheduled`, for
124
+ # --json), so say so rather than leaving them looking universal.
125
+ def add_getting_options(section)
126
+ section.option('--brief', 'Getting only: skip the presence, DND and schedule lookups')
127
+ section.option('--no-scheduled', 'Getting only: skip the scheduled lookup, keep presence and DND')
128
+ section.option('--json', 'Getting only: JSON for scripts (also `slk status scheduled`)')
129
+ end
130
+
105
131
  # These are ignored outside `schedule`, so say so rather than leaving
106
132
  # --with-dnd looking like a sibling of -d/--dnd.
107
133
  def add_scheduling_options(section)
@@ -115,36 +141,116 @@ module Slk
115
141
  def get_status # rubocop:disable Naming/AccessorMethodName
116
142
  # GET defaults to all workspaces unless -w specified
117
143
  workspaces = target_workspaces_for_get
144
+ snapshots = workspaces.map { |workspace| snapshot_for(workspace) }
145
+ return render_snapshots_json(snapshots) if @options[:json]
118
146
 
119
- workspaces.each do |workspace|
120
- status = runner.users_api(workspace.name).get_status
121
- print_workspace_status(workspaces, workspace, status)
147
+ snapshots.each { |snapshot| print_snapshot(snapshot, labelled: workspaces.size > 1) }
148
+ 0
149
+ end
150
+
151
+ # The status text is what was asked for. Presence, DND and the pending
152
+ # schedule are what it is usually being read *for* — whether anyone can
153
+ # reach you, and whether the status is about to change on its own — so
154
+ # they are gathered alongside it, one call each, none of them fatal.
155
+ def snapshot_for(workspace)
156
+ status = runner.users_api(workspace.name).get_status
157
+ return Models::StatusSnapshot.new(workspace: workspace, status: status) unless details?
158
+
159
+ Models::StatusSnapshot.new(workspace: workspace, status: status, **workspace_details(workspace))
160
+ end
161
+
162
+ def workspace_details(workspace)
163
+ {
164
+ presence: detail(workspace, 'presence') { runner.users_api(workspace.name).get_presence },
165
+ dnd: detail(workspace, 'DND') { Models::DndState.from_api(runner.dnd_api(workspace.name).info) },
166
+ scheduled: pending_scheduled(workspace)
167
+ }
168
+ end
169
+
170
+ # --brief drops the extra calls; under --quiet their output is discarded,
171
+ # so they would buy nothing. --json prints even when quiet, and its
172
+ # consumers are the ones that want the detail most.
173
+ def details?
174
+ return false if @options[:brief]
175
+
176
+ @options[:json] || !@options[:quiet]
177
+ end
178
+
179
+ def pending_scheduled(workspace)
180
+ return nil unless @options[:scheduled]
181
+
182
+ detail(workspace, 'scheduled statuses') do
183
+ in_order(runner.custom_status_api(workspace.name).scheduled)
122
184
  end
185
+ end
123
186
 
187
+ # nil, not a blank value: the caller records "not checked", which --json
188
+ # reports as null. Reporting a failed DND lookup as "DND off" would say
189
+ # the opposite of the truth to anything reading it.
190
+ def detail(workspace, label)
191
+ return nil if @details_unavailable
192
+
193
+ yield
194
+ rescue RateLimitError => e
195
+ # Being throttled while decorating the answer is a reason to stop
196
+ # decorating. These calls are optional; the status reads they would
197
+ # crowd out are not.
198
+ @details_unavailable = true
199
+ warn("Rate limited; skipping presence, DND and scheduled lookups: #{e.message}")
200
+ nil
201
+ rescue ApiError => e
202
+ warn("Could not read #{label} on #{workspace.name}: #{e.message}")
203
+ nil
204
+ end
205
+
206
+ def render_snapshots_json(snapshots)
207
+ output_json(json_formatter.format(snapshots))
124
208
  0
125
209
  end
126
210
 
211
+ def json_formatter = Formatters::JsonStatusFormatter.new
212
+
213
+ def print_snapshot(snapshot, labelled:)
214
+ puts output.bold(snapshot.workspace_name) if labelled
215
+ print_status_line(snapshot)
216
+ print_upcoming(snapshot.scheduled)
217
+ end
218
+
219
+ # A status that turns on this afternoon is the reason to leave the
220
+ # current one alone, so it is listed under it rather than a command away.
221
+ def print_upcoming(scheduled)
222
+ return if scheduled.nil? || scheduled.empty?
223
+
224
+ puts ' Scheduled:'
225
+ # No IDs here; `slk status scheduled` is the view you paste from.
226
+ scheduled.each { |status| puts " #{status}" }
227
+ end
228
+
127
229
  def target_workspaces_for_get
128
230
  @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
129
231
  end
130
232
 
131
- def print_workspace_status(workspaces, workspace, status)
132
- puts output.bold(workspace.name) if workspaces.size > 1
233
+ def print_status_line(snapshot)
234
+ suffix = snapshot.labels.join(' ')
133
235
 
134
- if status.empty?
135
- puts ' (no status set)'
236
+ if snapshot.status.empty?
237
+ # Away or on DND with no status is still worth saying: it is the
238
+ # difference between "nothing to report" and "unreachable".
239
+ puts join_suffix(' (no status set)', suffix)
136
240
  else
137
- display_status(workspace, status)
241
+ display_status(snapshot.workspace, snapshot.status, suffix)
138
242
  end
139
243
  end
140
244
 
141
- def display_status(workspace, status)
245
+ def join_suffix(line, suffix) = suffix.empty? ? line : "#{line} #{suffix}"
246
+
247
+ def display_status(workspace, status, suffix = '')
142
248
  emoji_path = workspace_emoji_path(workspace.name, status.emoji)
143
249
 
144
250
  if emoji_path && inline_images_supported?
145
- print_status_with_image(emoji_path, status)
251
+ print_status_with_image(emoji_path, status, suffix)
146
252
  else
147
- puts " #{status}"
253
+ puts join_suffix(" #{status}", suffix)
148
254
  end
149
255
  end
150
256
 
@@ -153,10 +259,11 @@ module Slk
153
259
  find_workspace_emoji(workspace_name, emoji_name)
154
260
  end
155
261
 
156
- def print_status_with_image(emoji_path, status)
262
+ def print_status_with_image(emoji_path, status, suffix = '')
157
263
  parts = []
158
264
  parts << status.text unless status.text.empty?
159
265
  parts << "(#{status.time_remaining})" if status.time_remaining
266
+ parts << suffix unless suffix.empty?
160
267
  print_inline_image_with_text(emoji_path, " #{parts.join(' ')}")
161
268
  end
162
269
 
@@ -347,6 +454,7 @@ module Slk
347
454
 
348
455
  def list_scheduled
349
456
  workspaces = target_workspaces_for_get
457
+ return list_scheduled_json(workspaces) if @options[:json]
350
458
 
351
459
  each_workspace_reporting(workspaces) do |workspace|
352
460
  puts output.bold(workspace.name) if workspaces.size > 1
@@ -354,12 +462,29 @@ module Slk
354
462
  end
355
463
  end
356
464
 
465
+ # Every workspace appears whether or not its lookup worked, so the array
466
+ # still lines up with the workspaces asked about; a failed one is null
467
+ # rather than an empty list, and its reason went to stderr.
468
+ def list_scheduled_json(workspaces)
469
+ pending = workspaces.to_h { |workspace| [workspace.name, nil] }
470
+
471
+ code = each_workspace_reporting(workspaces) do |workspace|
472
+ pending[workspace.name] = in_order(runner.custom_status_api(workspace.name).scheduled)
473
+ end
474
+
475
+ output_json(json_formatter.format_scheduled(pending))
476
+ code
477
+ end
478
+
357
479
  def print_scheduled(scheduled)
358
480
  return puts ' (none scheduled)' if scheduled.empty?
359
481
 
360
- scheduled.each { |status| puts " #{status.id} #{status}" }
482
+ in_order(scheduled).each { |status| puts " #{status.id} #{status}" }
361
483
  end
362
484
 
485
+ # Soonest first: the next one to turn on is the one being read for.
486
+ def in_order(scheduled) = scheduled.sort_by(&:date_scheduled)
487
+
363
488
  def unschedule_status(args)
364
489
  id = args.first
365
490
  return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Slk
6
+ module Formatters
7
+ # Renders `slk status` snapshots as JSON for scripts and statuslines.
8
+ #
9
+ # Two shape rules the consumers depend on:
10
+ #
11
+ # - Always an array, one entry per workspace, even for a single one. The
12
+ # workspace set changes with -w/--all, and a document whose shape
13
+ # changes with a flag cannot be parsed by a script that did not pass it.
14
+ # - null means "not checked" (skipped, or the lookup failed); an empty
15
+ # array or false means checked. A statusline that treats a failed DND
16
+ # lookup as "DND off" would quietly say the opposite of the truth.
17
+ #
18
+ # Timestamps appear twice: the raw Slack epoch under Slack's own field name
19
+ # and an ISO 8601 string beside it, so neither jq nor the shell has to do
20
+ # date arithmetic to print "until 3:00pm".
21
+ class JsonStatusFormatter
22
+ def format(snapshots)
23
+ snapshots.map { |snapshot| snapshot_hash(snapshot) }
24
+ end
25
+
26
+ # `slk status scheduled --json`: the same array, narrowed to the part
27
+ # that command is about, so a script can read either with one shape.
28
+ #
29
+ # @param by_workspace [Hash{String => Array<Models::ScheduledStatus>, nil}]
30
+ def format_scheduled(by_workspace)
31
+ by_workspace.map do |name, scheduled|
32
+ { workspace: name, scheduled: scheduled&.map { |status| scheduled_hash(status) } }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def snapshot_hash(snapshot)
39
+ {
40
+ workspace: snapshot.workspace_name,
41
+ status: status_hash(snapshot.status),
42
+ presence: presence_hash(snapshot.presence),
43
+ dnd: dnd_hash(snapshot.dnd),
44
+ scheduled: snapshot.scheduled&.map { |status| scheduled_hash(status) }
45
+ }
46
+ end
47
+
48
+ def status_hash(status)
49
+ {
50
+ text: status.text,
51
+ emoji: status.emoji,
52
+ # Slack's own 0-for-never, kept as-is; `expires_at` is the readable
53
+ # form and is null rather than "1970-01-01" when there is no expiry.
54
+ expiration: status.expiration,
55
+ expires_at: iso8601(status.expiration_time),
56
+ empty: status.empty?
57
+ }
58
+ end
59
+
60
+ def presence_hash(presence)
61
+ return nil unless presence
62
+
63
+ { presence: presence[:presence], manual_away: presence[:manual_away], online: presence[:online] }
64
+ end
65
+
66
+ def dnd_hash(dnd)
67
+ return nil unless dnd
68
+
69
+ {
70
+ active: dnd.active?,
71
+ source: dnd.source&.to_s,
72
+ snoozing: dnd.snoozing,
73
+ in_scheduled_hours: dnd.in_scheduled_hours?,
74
+ until: dnd.until_time&.to_i,
75
+ until_at: iso8601(dnd.until_time)
76
+ }
77
+ end
78
+
79
+ # Slack's own field names and epochs, plus a readable form of each
80
+ # timestamp. `slice` rather than the whole record on purpose: this is a
81
+ # published shape, and a field added to the model later should not join
82
+ # it without someone deciding to.
83
+ def scheduled_hash(status)
84
+ status.to_h.slice(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active)
85
+ .merge(starts_at: iso8601(status.starts_at), ends_at: iso8601(status.ends_at))
86
+ end
87
+
88
+ def iso8601(time) = time&.iso8601
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Slk
6
+ module Models
7
+ # Current Do Not Disturb state for one workspace, from dnd.info.
8
+ #
9
+ # Slack reports two independent things through that one endpoint: a manual
10
+ # snooze ("pause notifications for 2 hours") and the configured DND
11
+ # schedule ("quiet from 8pm to 8am"). From the outside they are the same
12
+ # thing — messages do not notify — so `active?` covers both and `source`
13
+ # says which one is responsible.
14
+ DndState = Data.define(:snoozing, :snooze_endtime, :scheduled, :next_start, :next_end) do
15
+ def self.from_api(data)
16
+ data = {} unless data.is_a?(Hash)
17
+
18
+ new(
19
+ snoozing: data['snooze_enabled'] == true,
20
+ snooze_endtime: data['snooze_endtime'].to_i,
21
+ scheduled: data['dnd_enabled'] == true,
22
+ next_start: data['next_dnd_start_ts'].to_i,
23
+ next_end: data['next_dnd_end_ts'].to_i
24
+ )
25
+ end
26
+
27
+ def active? = snoozing || in_scheduled_hours?
28
+
29
+ # `next_dnd_*` names the *next* window while DND hours are off and the
30
+ # current one while they are running, so "now falls inside it" is the
31
+ # only reading that means notifications are being held right now.
32
+ #
33
+ # Slack reports 1 for both schedule timestamps when no schedule is
34
+ # configured, so a bare `positive?` would read that as a window that
35
+ # opened in 1970 and never closed.
36
+ def in_scheduled_hours?
37
+ return false unless scheduled && next_start > 1 && next_end > next_start
38
+
39
+ now = Time.now.to_i
40
+ next_start <= now && now < next_end
41
+ end
42
+
43
+ def source
44
+ return :both if snoozing && in_scheduled_hours?
45
+ return :snooze if snoozing
46
+ return :schedule if in_scheduled_hours?
47
+
48
+ nil
49
+ end
50
+
51
+ # The later of the two when both apply: notifications stay off until
52
+ # every reason for holding them has expired, not the first.
53
+ def until_time
54
+ finishes = []
55
+ finishes << snooze_endtime if snoozing && snooze_endtime.positive?
56
+ finishes << next_end if in_scheduled_hours?
57
+
58
+ finish = finishes.max
59
+ finish ? Time.at(finish) : nil
60
+ end
61
+
62
+ # Same-day times need no date; an overnight schedule ending tomorrow
63
+ # morning would otherwise read as "until 8:00am" of a day already gone.
64
+ def until_label
65
+ finish = until_time or return nil
66
+ finish.to_date == Date.today ? finish.strftime('%-l:%M%P') : finish.strftime('%a %-l:%M%P')
67
+ end
68
+
69
+ # Empty when notifications are flowing: there is nothing to say, and a
70
+ # "[dnd off]" on every line would bury the workspace where it is on.
71
+ def to_s
72
+ return '' unless active?
73
+
74
+ label = until_label
75
+ # A snooze with no end time is a state Slack can report; saying "[dnd]"
76
+ # is honest about not knowing when it lifts.
77
+ label ? "[dnd until #{label}]" : '[dnd]'
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Everything `slk status` knows about one workspace at one moment: the
6
+ # status itself, plus the two things that decide whether anyone can reach
7
+ # you (presence, DND) and whatever is queued to replace the status later.
8
+ #
9
+ # Every part but the status is optional, and nil means "not checked" —
10
+ # skipped by a flag, or a lookup that failed. That is deliberately distinct
11
+ # from checked-and-empty: `scheduled: []` says nothing is queued, while
12
+ # `scheduled: nil` says nobody looked.
13
+ StatusSnapshot = Data.define(:workspace, :status, :presence, :dnd, :scheduled) do
14
+ def initialize(workspace:, status:, presence: nil, dnd: nil, scheduled: nil)
15
+ super
16
+ end
17
+
18
+ def workspace_name = workspace.name
19
+
20
+ def away? = presence ? presence[:presence] == 'away' : false
21
+
22
+ # Short suffixes for the status line. Only the exceptional states appear:
23
+ # active presence and DND-off are the common case, and repeating them on
24
+ # every line would bury the workspace that differs.
25
+ def labels
26
+ [('[away]' if away?), dnd&.to_s].reject { |label| label.to_s.empty? }
27
+ end
28
+ end
29
+ end
30
+ 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.7.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -67,6 +67,8 @@ module Slk
67
67
  autoload :Workspace, 'slk/models/workspace'
68
68
  autoload :Status, 'slk/models/status'
69
69
  autoload :ScheduledStatus, 'slk/models/scheduled_status'
70
+ autoload :DndState, 'slk/models/dnd_state'
71
+ autoload :StatusSnapshot, 'slk/models/status_snapshot'
70
72
  autoload :Message, 'slk/models/message'
71
73
  autoload :Reaction, 'slk/models/reaction'
72
74
  autoload :User, 'slk/models/user'
@@ -117,6 +119,7 @@ module Slk
117
119
  autoload :MessageFormatter, 'slk/formatters/message_formatter'
118
120
  autoload :ReactionFormatter, 'slk/formatters/reaction_formatter'
119
121
  autoload :JsonMessageFormatter, 'slk/formatters/json_message_formatter'
122
+ autoload :JsonStatusFormatter, 'slk/formatters/json_status_formatter'
120
123
  autoload :ActivityFormatter, 'slk/formatters/activity_formatter'
121
124
  autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
122
125
  autoload :BlockFormatter, 'slk/formatters/block_formatter'
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -67,6 +67,7 @@ files:
67
67
  - lib/slk/formatters/duration_formatter.rb
68
68
  - lib/slk/formatters/emoji_replacer.rb
69
69
  - lib/slk/formatters/json_message_formatter.rb
70
+ - lib/slk/formatters/json_status_formatter.rb
70
71
  - lib/slk/formatters/markdown_output.rb
71
72
  - lib/slk/formatters/mention_replacer.rb
72
73
  - lib/slk/formatters/message_formatter.rb
@@ -79,6 +80,7 @@ files:
79
80
  - lib/slk/formatters/search_formatter.rb
80
81
  - lib/slk/formatters/text_processor.rb
81
82
  - lib/slk/models/channel.rb
83
+ - lib/slk/models/dnd_state.rb
82
84
  - lib/slk/models/duration.rb
83
85
  - lib/slk/models/message.rb
84
86
  - lib/slk/models/preset.rb
@@ -89,6 +91,7 @@ files:
89
91
  - lib/slk/models/scheduled_status.rb
90
92
  - lib/slk/models/search_result.rb
91
93
  - lib/slk/models/status.rb
94
+ - lib/slk/models/status_snapshot.rb
92
95
  - lib/slk/models/user.rb
93
96
  - lib/slk/models/workspace.rb
94
97
  - lib/slk/runner.rb
@@ -152,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
155
  - !ruby/object:Gem::Version
153
156
  version: '0'
154
157
  requirements: []
155
- rubygems_version: 4.0.13
158
+ rubygems_version: 4.0.16
156
159
  specification_version: 4
157
160
  summary: A command-line interface for Slack
158
161
  test_files: []