slk 0.5.0 → 0.7.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 +83 -1
- data/README.md +31 -0
- data/lib/slk/api/custom_status.rb +112 -0
- data/lib/slk/api/team.rb +24 -0
- data/lib/slk/api/users.rb +10 -0
- data/lib/slk/cli.rb +14 -3
- data/lib/slk/commands/base.rb +13 -1
- data/lib/slk/commands/debug.rb +110 -0
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/org.rb +119 -0
- data/lib/slk/commands/status.rb +224 -13
- data/lib/slk/commands/who.rb +115 -0
- data/lib/slk/formatters/output.rb +2 -0
- data/lib/slk/formatters/profile_field_renderer.rb +107 -0
- data/lib/slk/formatters/profile_formatter.rb +87 -0
- data/lib/slk/formatters/profile_rows.rb +72 -0
- data/lib/slk/models/profile.rb +71 -0
- data/lib/slk/models/profile_field.rb +29 -0
- data/lib/slk/models/scheduled_status.rb +77 -0
- data/lib/slk/runner.rb +21 -0
- data/lib/slk/services/api_client.rb +75 -10
- data/lib/slk/services/cache_store.rb +46 -2
- data/lib/slk/services/encryption.rb +8 -1
- data/lib/slk/services/meta_cache.rb +32 -0
- data/lib/slk/services/profile_builder.rb +129 -0
- data/lib/slk/services/profile_resolver.rb +138 -0
- data/lib/slk/services/user_lookup.rb +9 -13
- data/lib/slk/services/user_matcher.rb +64 -0
- data/lib/slk/services/user_picker.rb +68 -0
- data/lib/slk/services/who_target_resolver.rb +64 -0
- data/lib/slk/support/time_parser.rb +120 -0
- data/lib/slk/support/time_range_parser.rb +166 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +59 -1
- metadata +21 -2
data/lib/slk/commands/status.rb
CHANGED
|
@@ -10,6 +10,10 @@ module Slk
|
|
|
10
10
|
class Status < Base
|
|
11
11
|
include Support::InlineImages
|
|
12
12
|
|
|
13
|
+
SCHEDULE_USAGE = 'Usage: slk status schedule "<text>" [:emoji:] <start-end> | --start WHEN [--end WHEN]'
|
|
14
|
+
MISSING_RANGE = 'Missing time range. Example: slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p ' \
|
|
15
|
+
'(or --start/--end for a multi-day window).'
|
|
16
|
+
|
|
13
17
|
def execute
|
|
14
18
|
result = validate_options
|
|
15
19
|
return result if result
|
|
@@ -22,6 +26,9 @@ module Slk
|
|
|
22
26
|
|
|
23
27
|
def dispatch_action
|
|
24
28
|
case positional_args
|
|
29
|
+
in ['schedule', *rest] then schedule_status(rest)
|
|
30
|
+
in ['scheduled', *] then list_scheduled
|
|
31
|
+
in ['unschedule', *rest] then unschedule_status(rest)
|
|
25
32
|
in ['clear', *] then clear_status
|
|
26
33
|
in [text, *rest] then set_status(text, rest)
|
|
27
34
|
in [] then get_status
|
|
@@ -31,17 +38,17 @@ module Slk
|
|
|
31
38
|
protected
|
|
32
39
|
|
|
33
40
|
def default_options
|
|
34
|
-
super.merge(presence: nil, dnd: nil)
|
|
41
|
+
super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil)
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
def handle_option(arg, args, remaining)
|
|
38
45
|
case arg
|
|
39
|
-
when '-p', '--presence'
|
|
40
|
-
|
|
41
|
-
when '-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
when '-p', '--presence' then @options[:presence] = option_value(arg, args)
|
|
47
|
+
when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
|
|
48
|
+
when '--with-dnd' then @options[:with_dnd] = true
|
|
49
|
+
when '--start' then @options[:start_at] = option_value(arg, args)
|
|
50
|
+
when '--end' then @options[:end_at] = option_value(arg, args)
|
|
51
|
+
else super
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -49,7 +56,10 @@ module Slk
|
|
|
49
56
|
help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
|
|
50
57
|
help.description('Get or set your Slack status.')
|
|
51
58
|
help.note('GET shows all workspaces by default. SET applies to primary only.')
|
|
59
|
+
help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
|
|
60
|
+
help.note('Slack allows at most 5 scheduled statuses at a time.')
|
|
52
61
|
add_examples_section(help)
|
|
62
|
+
add_scheduling_section(help)
|
|
53
63
|
add_options_section(help)
|
|
54
64
|
help.render
|
|
55
65
|
end
|
|
@@ -64,17 +74,42 @@ module Slk
|
|
|
64
74
|
end
|
|
65
75
|
end
|
|
66
76
|
|
|
77
|
+
def add_scheduling_section(help)
|
|
78
|
+
help.section('SCHEDULING') do |s|
|
|
79
|
+
s.example('slk status schedule "Vet Appt" :paw_prints: 1:30p-3:30p', 'Schedule for later')
|
|
80
|
+
s.example('slk status schedule "OOO" :palm_tree: 2026-08-04 9:00-17:00')
|
|
81
|
+
s.example('slk status schedule "OOO" :palm_tree: --start "2026-08-12 8a" --end "2026-08-14 5p"',
|
|
82
|
+
'Span multiple days')
|
|
83
|
+
s.example('slk status schedule "Heads down" :no_bell: --start 2p', 'No end; stays until cleared')
|
|
84
|
+
s.example('slk status scheduled', 'List pending scheduled statuses')
|
|
85
|
+
s.example('slk status unschedule CS0BMQDDGWTU', 'Cancel a scheduled status')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
67
89
|
def add_options_section(help)
|
|
68
90
|
help.section('OPTIONS') do |s|
|
|
69
|
-
s
|
|
70
|
-
s
|
|
71
|
-
s.option('-w, --workspace', 'Limit to specific workspace')
|
|
72
|
-
s.option('--all', 'Set across all workspaces')
|
|
73
|
-
s.option('-v, --verbose', 'Show debug information')
|
|
74
|
-
s.option('-q, --quiet', 'Suppress output')
|
|
91
|
+
add_general_options(s)
|
|
92
|
+
add_scheduling_options(s)
|
|
75
93
|
end
|
|
76
94
|
end
|
|
77
95
|
|
|
96
|
+
def add_general_options(section)
|
|
97
|
+
section.option('-p, --presence VALUE', 'Also set presence (away/auto/active)')
|
|
98
|
+
section.option('-d, --dnd DURATION', "Also set DND (or 'off')")
|
|
99
|
+
section.option('-w, --workspace', 'Limit to specific workspace')
|
|
100
|
+
section.option('--all', 'Set across all workspaces')
|
|
101
|
+
section.option('-v, --verbose', 'Show debug information')
|
|
102
|
+
section.option('-q, --quiet', 'Suppress output')
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# These are ignored outside `schedule`, so say so rather than leaving
|
|
106
|
+
# --with-dnd looking like a sibling of -d/--dnd.
|
|
107
|
+
def add_scheduling_options(section)
|
|
108
|
+
section.option('--with-dnd', 'Scheduling only: pause notifications while the status is active')
|
|
109
|
+
section.option('--start WHEN', 'Scheduling only: window start, "[YYYY-MM-DD ]TIME"')
|
|
110
|
+
section.option('--end WHEN', 'Scheduling only: window end; omit for no expiry')
|
|
111
|
+
end
|
|
112
|
+
|
|
78
113
|
private
|
|
79
114
|
|
|
80
115
|
def get_status # rubocop:disable Naming/AccessorMethodName
|
|
@@ -210,6 +245,182 @@ module Slk
|
|
|
210
245
|
0
|
|
211
246
|
end
|
|
212
247
|
|
|
248
|
+
def schedule_status(args)
|
|
249
|
+
text, *rest = args
|
|
250
|
+
return error(SCHEDULE_USAGE) if text.to_s.strip.empty?
|
|
251
|
+
|
|
252
|
+
window = parse_schedule_window(rest)
|
|
253
|
+
return 1 unless window
|
|
254
|
+
|
|
255
|
+
result = create_scheduled_status(text, extract_emoji(rest), *window)
|
|
256
|
+
show_all_workspaces_hint
|
|
257
|
+
result
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Narrow on both axes: only the parsing is covered, so a Slack failure is
|
|
261
|
+
# not reported as a mistyped range, and only TimeFormatError is caught, so
|
|
262
|
+
# an arity or range error from this code does not surface as user error.
|
|
263
|
+
def parse_schedule_window(rest)
|
|
264
|
+
return flag_window(rest) if @options[:start_at] || @options[:end_at]
|
|
265
|
+
|
|
266
|
+
range = extract_time_range(rest)
|
|
267
|
+
return reject_window(MISSING_RANGE) unless range
|
|
268
|
+
|
|
269
|
+
Support::TimeRangeParser.parse(range)
|
|
270
|
+
rescue TimeFormatError => e
|
|
271
|
+
error(e.message)
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --start/--end is the general form. Both times carry their own date, so
|
|
276
|
+
# it reaches multi-day windows the positional range cannot express, and
|
|
277
|
+
# omitting --end schedules a status that never auto-clears.
|
|
278
|
+
def flag_window(rest)
|
|
279
|
+
return reject_window('--end requires --start.') unless @options[:start_at]
|
|
280
|
+
return reject_window('Use either a time range or --start/--end, not both.') if extract_time_range(rest)
|
|
281
|
+
|
|
282
|
+
starts_at = Support::TimeParser.parse(@options[:start_at])
|
|
283
|
+
return [starts_at, nil] unless @options[:end_at]
|
|
284
|
+
|
|
285
|
+
[starts_at, validated_end(starts_at)]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def validated_end(starts_at)
|
|
289
|
+
ends_at = Support::TimeParser.parse(@options[:end_at])
|
|
290
|
+
# An explicit end is unambiguous, so there is nothing to roll forward.
|
|
291
|
+
return ends_at if ends_at > starts_at
|
|
292
|
+
|
|
293
|
+
raise TimeFormatError, "--end #{@options[:end_at]} is not after --start #{@options[:start_at]}."
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Returns nil so the caller reads it as "no window, already reported".
|
|
297
|
+
def reject_window(message)
|
|
298
|
+
error(message)
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# The range may arrive as one token ("1:30p-3:30p") or several
|
|
303
|
+
# ("2026-08-04 13:30-15:30"), so the non-emoji arguments are matched as a
|
|
304
|
+
# whole. Emoji are dropped first because the pattern is \A-anchored and a
|
|
305
|
+
# leading ":palm_tree:" would stop the date form from matching at all.
|
|
306
|
+
#
|
|
307
|
+
# Everything left has to be part of the range. Only YYYY-MM-DD dates
|
|
308
|
+
# parse, so picking the range out and discarding "8/4" or "tomorrow"
|
|
309
|
+
# would silently schedule the status for today.
|
|
310
|
+
def extract_time_range(rest)
|
|
311
|
+
candidates = rest.reject { |arg| arg.start_with?(':') && arg.end_with?(':') }
|
|
312
|
+
return nil if candidates.empty?
|
|
313
|
+
|
|
314
|
+
joined = candidates.join(' ')
|
|
315
|
+
return joined if Support::TimeRangeParser.match?(joined)
|
|
316
|
+
|
|
317
|
+
raise TimeFormatError, "Unrecognized argument: #{joined}. Use #{Support::TimeRangeParser::EXAMPLE}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Each workspace is an independent call, so one failure must not strand
|
|
321
|
+
# the workspaces after it: the user would re-run to fix the tail and
|
|
322
|
+
# double-schedule everything that already succeeded.
|
|
323
|
+
def each_workspace_reporting(workspaces)
|
|
324
|
+
failed = false
|
|
325
|
+
|
|
326
|
+
workspaces.each do |workspace|
|
|
327
|
+
yield workspace
|
|
328
|
+
rescue ApiError => e
|
|
329
|
+
failed = true
|
|
330
|
+
error("#{workspace.name}: #{e.message}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
failed ? 1 : 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def create_scheduled_status(text, emoji, starts_at, ends_at)
|
|
337
|
+
each_workspace_reporting(target_workspaces) do |workspace|
|
|
338
|
+
scheduled = runner.custom_status_api(workspace.name).schedule(
|
|
339
|
+
text: text, emoji: emoji,
|
|
340
|
+
date_scheduled: starts_at, date_expire: ends_at,
|
|
341
|
+
dnd: @options[:with_dnd]
|
|
342
|
+
)
|
|
343
|
+
success("Scheduled on #{workspace.name}: #{scheduled}")
|
|
344
|
+
debug(" ID: #{scheduled.id}")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def list_scheduled
|
|
349
|
+
workspaces = target_workspaces_for_get
|
|
350
|
+
|
|
351
|
+
each_workspace_reporting(workspaces) do |workspace|
|
|
352
|
+
puts output.bold(workspace.name) if workspaces.size > 1
|
|
353
|
+
print_scheduled(runner.custom_status_api(workspace.name).scheduled)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def print_scheduled(scheduled)
|
|
358
|
+
return puts ' (none scheduled)' if scheduled.empty?
|
|
359
|
+
|
|
360
|
+
scheduled.each { |status| puts " #{status.id} #{status}" }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def unschedule_status(args)
|
|
364
|
+
id = args.first
|
|
365
|
+
return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
|
|
366
|
+
|
|
367
|
+
@unchecked = []
|
|
368
|
+
targets = unschedule_targets(id)
|
|
369
|
+
return report_id_not_found(id) if targets.empty?
|
|
370
|
+
|
|
371
|
+
each_workspace_reporting(targets) do |workspace|
|
|
372
|
+
outcome, reason = runner.custom_status_api(workspace.name).delete_scheduled(id)
|
|
373
|
+
report_cancelled(workspace, outcome, reason)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# The delete succeeded either way, so both are successes — but only one
|
|
378
|
+
# of them has been checked, and saying so is the difference between the
|
|
379
|
+
# user moving on and the user re-cancelling something already gone.
|
|
380
|
+
def report_cancelled(workspace, outcome, reason)
|
|
381
|
+
return success("Cancelled scheduled status on #{workspace.name}") if outcome == :cancelled
|
|
382
|
+
|
|
383
|
+
warn("Cancelled scheduled status on #{workspace.name}, but could not confirm it: #{reason}")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# `slk status scheduled` lists every workspace, so the obvious next step
|
|
387
|
+
# is to paste an ID straight back in. Defaulting to the primary workspace
|
|
388
|
+
# made that fail with a bare Slack error whenever the ID came from
|
|
389
|
+
# another one, so look up the owner instead. An explicit -w/--all still
|
|
390
|
+
# wins, and a lone workspace needs no lookup.
|
|
391
|
+
def unschedule_targets(id)
|
|
392
|
+
return target_workspaces if @options[:all] || @options[:workspace]
|
|
393
|
+
|
|
394
|
+
workspaces = runner.all_workspaces
|
|
395
|
+
return workspaces if workspaces.size <= 1
|
|
396
|
+
|
|
397
|
+
[workspaces.find { |workspace| owns_scheduled?(workspace, id) }].compact
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# "Not found" is only true of the workspaces we actually reached. Saying
|
|
401
|
+
# it flatly after warning that one could not be checked contradicts the
|
|
402
|
+
# warning, and pointing at `slk status scheduled` would just fail the
|
|
403
|
+
# same way — it calls the same endpoint.
|
|
404
|
+
def report_id_not_found(id)
|
|
405
|
+
return error("No scheduled status #{id} found. Run 'slk status scheduled' to list IDs.") if @unchecked.empty?
|
|
406
|
+
|
|
407
|
+
error("#{id} was not found, but #{@unchecked.join(', ')} could not be checked. " \
|
|
408
|
+
'Retry, or name the workspace with -w to cancel it directly.')
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def owns_scheduled?(workspace, id)
|
|
412
|
+
runner.custom_status_api(workspace.name).scheduled.any? { |status| status.id == id }
|
|
413
|
+
rescue RateLimitError
|
|
414
|
+
# Every remaining check spends another call against the same limit, so
|
|
415
|
+
# continuing turns one rate limit into several and still cannot answer.
|
|
416
|
+
raise
|
|
417
|
+
rescue ApiError => e
|
|
418
|
+
# Skipping it silently would report "not found" for an ID that exists.
|
|
419
|
+
warn("Could not check #{workspace.name}: #{e.message}")
|
|
420
|
+
@unchecked << workspace.name
|
|
421
|
+
false
|
|
422
|
+
end
|
|
423
|
+
|
|
213
424
|
def show_all_workspaces_hint
|
|
214
425
|
# Show hint if user has multiple workspaces and didn't use --all or -w
|
|
215
426
|
return if @options[:all] || @options[:workspace]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# Display a Slack user profile (teems-style compact card by default).
|
|
6
|
+
# Examples:
|
|
7
|
+
# slk who # self
|
|
8
|
+
# slk who @alex
|
|
9
|
+
# slk who alice
|
|
10
|
+
# slk who Uxxx --full
|
|
11
|
+
# slk who --json
|
|
12
|
+
class Who < Base
|
|
13
|
+
def execute
|
|
14
|
+
result = validate_options
|
|
15
|
+
return result if result
|
|
16
|
+
|
|
17
|
+
run
|
|
18
|
+
rescue ApiError => e
|
|
19
|
+
error("API error: #{e.message}")
|
|
20
|
+
1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def handle_option(arg, args, _remaining)
|
|
26
|
+
case arg
|
|
27
|
+
when '--full' then @options[:full] = true
|
|
28
|
+
when '--no-cache', '--refresh' then @options[:refresh] = true
|
|
29
|
+
when '--all' then @options[:all] = true
|
|
30
|
+
when '--pick' then @options[:pick] = parse_pick(args.shift)
|
|
31
|
+
else return super
|
|
32
|
+
end
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def help_text
|
|
37
|
+
<<~HELP
|
|
38
|
+
slk who [target]
|
|
39
|
+
|
|
40
|
+
Show a Slack user profile.
|
|
41
|
+
|
|
42
|
+
TARGET
|
|
43
|
+
(none) Self
|
|
44
|
+
@handle | name Resolved via user cache
|
|
45
|
+
Uxxx Raw user ID
|
|
46
|
+
|
|
47
|
+
OPTIONS
|
|
48
|
+
--full Section-grouped layout (Contact, People, About me)
|
|
49
|
+
--json Raw JSON output
|
|
50
|
+
--refresh Bypass cache
|
|
51
|
+
--all Render every match (skips the disambiguation prompt)
|
|
52
|
+
--pick N Auto-select match N when a name resolves to multiple
|
|
53
|
+
HELP
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_pick(value)
|
|
59
|
+
Integer(value)
|
|
60
|
+
rescue ArgumentError, TypeError
|
|
61
|
+
raise ApiError, "--pick expects an integer (got #{value.inspect})"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run
|
|
65
|
+
workspace = runner.workspace(@options[:workspace])
|
|
66
|
+
profiles = resolve_profiles(workspace)
|
|
67
|
+
return output_json_profiles(profiles) if @options[:json]
|
|
68
|
+
|
|
69
|
+
render_profiles(profiles)
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_profiles(workspace)
|
|
74
|
+
resolver = Services::WhoTargetResolver.new(
|
|
75
|
+
workspace: workspace, cache_store: cache_store,
|
|
76
|
+
api_client: api_client, output: output, options: @options
|
|
77
|
+
)
|
|
78
|
+
resolver.resolve(positional_args.first).map { |id| load_profile(workspace, id) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def load_profile(workspace, user_id)
|
|
82
|
+
runner.profile_resolver(workspace.name, refresh: @options[:refresh])
|
|
83
|
+
.resolve_with_people(user_id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_profiles(profiles)
|
|
87
|
+
profiles.each_with_index do |profile, idx|
|
|
88
|
+
output.puts(output.gray('—' * 40)) if idx.positive?
|
|
89
|
+
render(profile)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render(profile)
|
|
94
|
+
formatter = Formatters::ProfileFormatter.new(
|
|
95
|
+
output: output, emoji_replacer: runner.emoji_replacer
|
|
96
|
+
)
|
|
97
|
+
@options[:full] ? formatter.full(profile) : formatter.compact(profile)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def output_json_profiles(profiles)
|
|
101
|
+
payload = profiles.map { |p| profile_payload(p) }
|
|
102
|
+
output_json(profiles.size == 1 ? payload.first : payload)
|
|
103
|
+
0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def profile_payload(profile)
|
|
107
|
+
profile.to_h.merge(
|
|
108
|
+
custom_fields: profile.visible_fields.map(&:to_h),
|
|
109
|
+
sections: profile.sections,
|
|
110
|
+
resolved_users: profile.resolved_users.transform_values(&:to_h)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders ProfileField values to display strings (date with relative
|
|
6
|
+
# phrasing, link with alt text, type:user dereferenced via resolved_users).
|
|
7
|
+
class ProfileFieldRenderer
|
|
8
|
+
def initialize(output:)
|
|
9
|
+
@output = output
|
|
10
|
+
@hyperlinks = output.respond_to?(:color?) && output.color?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(field, profile)
|
|
14
|
+
case field.type
|
|
15
|
+
when 'date' then format_date(field.value)
|
|
16
|
+
when 'link' then format_link(field)
|
|
17
|
+
when 'user' then format_user_list(field, profile)
|
|
18
|
+
else format_text(field.value)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Slack stores some text fields (notably free-text profile blocks) as
|
|
23
|
+
# rich_text JSON blocks. Detect and flatten to plain text.
|
|
24
|
+
def format_text(value)
|
|
25
|
+
text = value.to_s
|
|
26
|
+
return text unless text.start_with?('[{') && text.include?('rich_text')
|
|
27
|
+
|
|
28
|
+
flatten_rich_text(JSON.parse(text))
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
text
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flatten_rich_text(blocks)
|
|
34
|
+
Array(blocks).flat_map { |block| extract_text_from_block(block) }.join
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def extract_text_from_block(block)
|
|
38
|
+
return [block.to_s] unless block.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
return [block['text'].to_s] if block['type'] == 'text'
|
|
41
|
+
return [block['name'] ? ":#{block['name']}:" : ''] if block['type'] == 'emoji'
|
|
42
|
+
|
|
43
|
+
Array(block['elements']).flat_map { |el| extract_text_from_block(el) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render_user_reference(user_id, profile)
|
|
47
|
+
ref = profile.resolved_users[user_id]
|
|
48
|
+
return user_id unless ref
|
|
49
|
+
|
|
50
|
+
suffix = ref.title.to_s.empty? ? '' : " — #{ref.title}"
|
|
51
|
+
pronouns = ref.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{ref.pronouns})")}"
|
|
52
|
+
"#{ref.best_name}#{pronouns}#{suffix}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def format_user_list(field, profile)
|
|
58
|
+
field.user_ids.map { |uid| render_user_reference(uid, profile) }.join(', ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_date(value)
|
|
62
|
+
date = parse_date(value) or return value
|
|
63
|
+
relative = relative_phrase(date, Date.today)
|
|
64
|
+
formatted = date.strftime('%b %-d, %Y')
|
|
65
|
+
relative ? "#{formatted} (#{relative})" : formatted
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_date(value)
|
|
69
|
+
Date.parse(value.to_s)
|
|
70
|
+
rescue ArgumentError, TypeError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative_phrase(then_date, now_date)
|
|
75
|
+
return nil if then_date >= now_date
|
|
76
|
+
|
|
77
|
+
years, months = years_months_between(then_date, now_date)
|
|
78
|
+
parts = []
|
|
79
|
+
parts << "#{years}y" if years.positive?
|
|
80
|
+
parts << "#{months}mo" if months.positive?
|
|
81
|
+
parts.empty? ? nil : "#{parts.join(' ')} ago"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def years_months_between(then_date, now_date)
|
|
85
|
+
months = ((now_date.year - then_date.year) * 12) + (now_date.month - then_date.month)
|
|
86
|
+
months -= 1 if now_date.day < then_date.day
|
|
87
|
+
[months / 12, months % 12]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def format_link(field)
|
|
91
|
+
url = field.value.to_s
|
|
92
|
+
label = field.alt.to_s.empty? ? url : field.alt
|
|
93
|
+
return url if label == url
|
|
94
|
+
return "#{label} (#{shorten_url(url)})" unless @hyperlinks
|
|
95
|
+
|
|
96
|
+
# OSC 8 hyperlink — clickable in iTerm2, Ghostty, Wezterm, Kitty, Vte.
|
|
97
|
+
"\e]8;;#{url}\a#{label}\e]8;;\a"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def shorten_url(url)
|
|
101
|
+
URI.parse(url).host || url
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
url
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Renders a Models::Profile for `slk who`.
|
|
6
|
+
# Compact mode: teems-style two-column card.
|
|
7
|
+
# Full mode: section-grouped layout matching Slack web UI.
|
|
8
|
+
class ProfileFormatter
|
|
9
|
+
MIN_LABEL_WIDTH = 8
|
|
10
|
+
MAX_LABEL_WIDTH = 20
|
|
11
|
+
|
|
12
|
+
def initialize(output:, emoji_replacer: nil)
|
|
13
|
+
@output = output
|
|
14
|
+
field_renderer = ProfileFieldRenderer.new(output: output)
|
|
15
|
+
@rows = ProfileRows.new(field_renderer: field_renderer, emoji_replacer: emoji_replacer)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compact(profile)
|
|
19
|
+
render_header(profile)
|
|
20
|
+
emit_rows(@rows.compact(profile))
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def full(profile)
|
|
25
|
+
render_header(profile)
|
|
26
|
+
return emit_rows(@rows.external(profile)) if profile.external?
|
|
27
|
+
|
|
28
|
+
render_section('Contact information', @rows.contact(profile))
|
|
29
|
+
render_section('People', @rows.people(profile))
|
|
30
|
+
render_section('About me', @rows.about(profile))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def emit_rows(rows)
|
|
34
|
+
non_empty = rows.reject { |_, v| v.nil? || v.to_s.empty? }
|
|
35
|
+
width = label_width(non_empty)
|
|
36
|
+
non_empty.each { |label, value| emit_row(label, value, width) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def label_width(rows)
|
|
40
|
+
max = rows.map { |label, _| label_for(label).length }.max || 0
|
|
41
|
+
max.clamp(MIN_LABEL_WIDTH, MAX_LABEL_WIDTH)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def label_for(label)
|
|
45
|
+
text = label.to_s
|
|
46
|
+
text.length > MAX_LABEL_WIDTH ? "#{text[0, MAX_LABEL_WIDTH - 1]}…" : text
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def render_header(profile)
|
|
52
|
+
@output.puts(@output.bold(profile.best_name) + pronouns_suffix(profile))
|
|
53
|
+
header_tags(profile).each { |tag| @output.puts(" #{tag}") }
|
|
54
|
+
@output.puts
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def header_tags(profile)
|
|
58
|
+
tags = []
|
|
59
|
+
tags << profile.title unless profile.title.to_s.empty?
|
|
60
|
+
tags << external_tag(profile) if profile.external?
|
|
61
|
+
tags << @output.bold('deactivated account') if profile.deleted
|
|
62
|
+
tags
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pronouns_suffix(profile)
|
|
66
|
+
profile.pronouns.to_s.empty? ? '' : " #{@output.gray("(#{profile.pronouns})")}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def external_tag(profile)
|
|
70
|
+
@output.gray("external — #{profile.home_team_name || 'external workspace'}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_section(title, rows)
|
|
74
|
+
return if rows.reject { |_, v| v.nil? || v.to_s.empty? }.empty?
|
|
75
|
+
|
|
76
|
+
@output.puts(@output.bold(title))
|
|
77
|
+
emit_rows(rows)
|
|
78
|
+
@output.puts
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def emit_row(label, value, width)
|
|
82
|
+
padded = label_for(label).ljust(width)
|
|
83
|
+
@output.puts(" #{@output.gray(padded)} #{value}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Builds [label, value] row pairs from a Models::Profile.
|
|
6
|
+
# Pure data — no output, formatting handled by ProfileFormatter.
|
|
7
|
+
class ProfileRows
|
|
8
|
+
def initialize(field_renderer:, emoji_replacer: nil)
|
|
9
|
+
@fields = field_renderer
|
|
10
|
+
@emoji_replacer = emoji_replacer
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compact(profile)
|
|
14
|
+
contact(profile) + base(profile) + people(profile) + about(profile, skip_title: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def contact(profile)
|
|
18
|
+
[['Email', profile.email], ['Phone', profile.phone]]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def base(profile)
|
|
22
|
+
[
|
|
23
|
+
['Presence', profile.presence_label],
|
|
24
|
+
['Status', status_text(profile)],
|
|
25
|
+
['Local', local_time(profile)]
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def people(profile)
|
|
30
|
+
profile.people_fields.flat_map do |field|
|
|
31
|
+
field.user_ids.map { |uid| [field.label, @fields.render_user_reference(uid, profile)] }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def about(profile, skip_title: false)
|
|
36
|
+
fields = profile.visible_fields.reject { |f| f.type == 'user' }
|
|
37
|
+
fields = fields.reject { |f| skip_title && duplicate_title?(f, profile) }
|
|
38
|
+
fields.map { |f| [f.label, @fields.render(f, profile)] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def external(profile)
|
|
42
|
+
contact(profile) + [['Workspace', profile.home_team_name]]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def duplicate_title?(field, profile)
|
|
48
|
+
field.label.casecmp('Title').zero? && field.value.to_s == profile.title.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def status_text(profile)
|
|
52
|
+
return nil if profile.status_text.empty? && profile.status_emoji.empty?
|
|
53
|
+
|
|
54
|
+
emoji = render_emoji(profile.status_emoji)
|
|
55
|
+
[emoji, profile.status_text].reject(&:empty?).join(' ')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_emoji(text)
|
|
59
|
+
return text if text.to_s.empty? || @emoji_replacer.nil?
|
|
60
|
+
|
|
61
|
+
@emoji_replacer.replace(text)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def local_time(profile)
|
|
65
|
+
return nil unless profile.tz
|
|
66
|
+
|
|
67
|
+
time_at_user = Time.now.utc + profile.tz_offset.to_i
|
|
68
|
+
"#{time_at_user.strftime('%-l:%M %p')} #{profile.tz_label}".strip
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|