slk 0.6.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 +4 -4
- data/CHANGELOG.md +50 -1
- data/README.md +72 -1
- data/lib/slk/api/custom_status.rb +112 -0
- data/lib/slk/cli.rb +5 -2
- data/lib/slk/commands/base.rb +13 -1
- data/lib/slk/commands/help.rb +1 -0
- data/lib/slk/commands/status.rb +363 -27
- data/lib/slk/formatters/json_status_formatter.rb +91 -0
- data/lib/slk/models/dnd_state.rb +81 -0
- data/lib/slk/models/scheduled_status.rb +77 -0
- data/lib/slk/models/status_snapshot.rb +30 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/encryption.rb +8 -1
- 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 +18 -0
- metadata +9 -2
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Models
|
|
5
|
+
# A status queued to turn on later, from users.customStatus.list.
|
|
6
|
+
#
|
|
7
|
+
# Slack derives `duration` server-side from the window, so it is not
|
|
8
|
+
# modelled here — the window itself is the source of truth.
|
|
9
|
+
ScheduledStatus = Data.define(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active) do
|
|
10
|
+
def self.from_api(data)
|
|
11
|
+
validate!(data)
|
|
12
|
+
|
|
13
|
+
new(
|
|
14
|
+
id: data['id'].to_s,
|
|
15
|
+
text: data['text'].to_s,
|
|
16
|
+
emoji: data['emoji'].to_s,
|
|
17
|
+
date_scheduled: data['date_scheduled'].to_i,
|
|
18
|
+
date_expire: data['date_expire'].to_i,
|
|
19
|
+
dnd: truthy?(data['is_dnd']),
|
|
20
|
+
active: truthy?(data['is_active'])
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every field is coerced, so without this guard any payload at all —
|
|
25
|
+
# a bare string, nil, a hash of unexpected keys — becomes a valid-looking
|
|
26
|
+
# record with an empty id that prints as a blank line and never matches
|
|
27
|
+
# the id the user asked about. An unusable record is a protocol change,
|
|
28
|
+
# not a status.
|
|
29
|
+
def self.validate!(data)
|
|
30
|
+
return if data.is_a?(Hash) && !data['id'].to_s.empty?
|
|
31
|
+
|
|
32
|
+
raise ApiError.new("Slack returned a scheduled status with no id: #{data.inspect[0, 120]}",
|
|
33
|
+
code: :malformed_scheduled_status)
|
|
34
|
+
end
|
|
35
|
+
private_class_method :validate!
|
|
36
|
+
|
|
37
|
+
# These endpoints are string-typed on the way in — Api::CustomStatus sends
|
|
38
|
+
# is_dnd as 'true' — so a strict `== true` would quietly read a scheduled
|
|
39
|
+
# DND back as off. Accept the shapes Slack actually uses.
|
|
40
|
+
def self.truthy?(value) = [true, 'true', 1, '1'].include?(value)
|
|
41
|
+
private_class_method :truthy?
|
|
42
|
+
|
|
43
|
+
def starts_at = date_scheduled.positive? ? Time.at(date_scheduled) : nil
|
|
44
|
+
def ends_at = date_expire.positive? ? Time.at(date_expire) : nil
|
|
45
|
+
|
|
46
|
+
def to_s
|
|
47
|
+
span = window
|
|
48
|
+
[
|
|
49
|
+
emoji,
|
|
50
|
+
text,
|
|
51
|
+
span.empty? ? '' : "(#{span})",
|
|
52
|
+
('[dnd]' if dnd),
|
|
53
|
+
# Slack marks the one that has already turned on. Worth showing: it
|
|
54
|
+
# is the difference between "will happen" and "is happening".
|
|
55
|
+
('[active]' if active)
|
|
56
|
+
].reject { |part| part.to_s.empty? }.join(' ')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Human-readable window; the end date is only repeated when it differs
|
|
60
|
+
# from the start date, which makes multi-day windows obvious.
|
|
61
|
+
def window
|
|
62
|
+
dated = '%a %b %-d %-l:%M%P'
|
|
63
|
+
start_time = starts_at or return ''
|
|
64
|
+
formatted = start_time.strftime(dated)
|
|
65
|
+
finish = ends_at or return formatted
|
|
66
|
+
|
|
67
|
+
"#{formatted} -> #{finish.strftime(same_day?(start_time, finish) ? '%-l:%M%P' : dated)}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def same_day?(start_time, finish)
|
|
73
|
+
start_time.to_date == finish.to_date
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
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/runner.rb
CHANGED
|
@@ -88,6 +88,10 @@ module Slk
|
|
|
88
88
|
Api::Saved.new(@api_client, workspace(workspace_name))
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def custom_status_api(workspace_name = nil)
|
|
92
|
+
Api::CustomStatus.new(@api_client, workspace(workspace_name))
|
|
93
|
+
end
|
|
94
|
+
|
|
91
95
|
def team_api(workspace_name = nil)
|
|
92
96
|
Api::Team.new(@api_client, workspace(workspace_name))
|
|
93
97
|
end
|
|
@@ -104,8 +104,15 @@ module Slk
|
|
|
104
104
|
'Please provide the correct public key for this private key.'
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# `-P ''` supplies the passphrase up front so ssh-keygen cannot ask for
|
|
108
|
+
# one. Without it, an encrypted key makes ssh-keygen prompt — and it
|
|
109
|
+
# prompts on the console directly, not on the stdin capture3 hands it, so
|
|
110
|
+
# closing stdin does not help. On Windows that read blocks forever; this
|
|
111
|
+
# hung the CI job until its timeout with no output. An empty passphrase
|
|
112
|
+
# fails an encrypted key with the same message it already reported, which
|
|
113
|
+
# is the right answer regardless: slk cannot use such a key anyway.
|
|
107
114
|
def derive_public_key(private_key_path)
|
|
108
|
-
output, error, status = Open3.capture3('ssh-keygen', '-y', '-f', private_key_path)
|
|
115
|
+
output, error, status = Open3.capture3('ssh-keygen', '-y', '-P', '', '-f', private_key_path)
|
|
109
116
|
return output.strip if status.success?
|
|
110
117
|
|
|
111
118
|
# Check if ssh-keygen is missing vs other failures
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Slk
|
|
7
|
+
module Support
|
|
8
|
+
# Resolves a single clock time ("1:30p", "2026-08-12 8a") to a Unix
|
|
9
|
+
# timestamp. A bare time at or before now rolls to tomorrow; an explicit
|
|
10
|
+
# YYYY-MM-DD date is honoured as given.
|
|
11
|
+
#
|
|
12
|
+
# TimeRangeParser builds the two-sided form on the clock arithmetic here.
|
|
13
|
+
class TimeParser
|
|
14
|
+
TIME = /(\d{1,2})(?::(\d{2}))?\s*([ap]m?)?/i
|
|
15
|
+
PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TIME}\z/i
|
|
16
|
+
|
|
17
|
+
EXAMPLE = '1:30p or 2026-08-12 8:00'
|
|
18
|
+
|
|
19
|
+
MINUTES_PER_DAY = 24 * 60
|
|
20
|
+
|
|
21
|
+
# A bare hour of 0 or 13-23 can only be 24-hour notation, so no am/pm was
|
|
22
|
+
# omitted. Anything in 1..12 is genuinely ambiguous without one.
|
|
23
|
+
CLOCK_HOURS = (1..12)
|
|
24
|
+
|
|
25
|
+
# @param now [Time] reference point for rolling bare times forward
|
|
26
|
+
# @return [Integer] Unix timestamp
|
|
27
|
+
def self.parse(input, now: Time.now) = new(now: now).parse(input)
|
|
28
|
+
|
|
29
|
+
def initialize(now: Time.now)
|
|
30
|
+
@now = now
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse(input)
|
|
34
|
+
match = PATTERN.match(input.to_s.strip)
|
|
35
|
+
raise TimeFormatError, "Invalid time: #{input}. Use #{EXAMPLE}" unless match
|
|
36
|
+
|
|
37
|
+
parts = match.captures[1..3]
|
|
38
|
+
date = match[1] ? parse_date(match[1]) : roll_forward(parts)
|
|
39
|
+
at(date, *parts).to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The rest of this class is clock arithmetic shared with TimeRangeParser,
|
|
43
|
+
# which needs to place the same parts on dates it works out itself.
|
|
44
|
+
|
|
45
|
+
# @param example [String] format hint, so a caller with its own syntax
|
|
46
|
+
# (TimeRangeParser) does not advertise this class's example
|
|
47
|
+
def parse_date(text, example: EXAMPLE)
|
|
48
|
+
Date.parse(text)
|
|
49
|
+
rescue Date::Error
|
|
50
|
+
raise TimeFormatError, "Invalid date: #{text}. Use #{example}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def at(date, *parts)
|
|
54
|
+
hours, minutes = to_24_hour(*parts)
|
|
55
|
+
time = build(date, hours, minutes)
|
|
56
|
+
# Ruby silently shifts a local time that DST skips (2:30a on a
|
|
57
|
+
# spring-forward date becomes 3:30a), which would move the window
|
|
58
|
+
# rather than fail. Reject it instead.
|
|
59
|
+
return time if time.hour == hours && time.min == minutes
|
|
60
|
+
|
|
61
|
+
raise TimeFormatError,
|
|
62
|
+
format('%<clock>s does not exist on %<date>s (clocks skip forward for DST).',
|
|
63
|
+
clock: format('%<h>02d:%<m>02d', h: hours, m: minutes), date: date)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Same placement without the existence check, for callers asking a
|
|
67
|
+
# question a nonexistent time still answers. `at` would raise, which for
|
|
68
|
+
# the roll-forward probe would reject "2:30a" outright on a spring-forward
|
|
69
|
+
# date instead of rolling it to the next day, where it does exist.
|
|
70
|
+
def place(date, *parts) = build(date, *to_24_hour(*parts))
|
|
71
|
+
|
|
72
|
+
# Minutes since midnight, for comparing two times before either has a date.
|
|
73
|
+
def minutes(parts)
|
|
74
|
+
hours, mins = to_24_hour(*parts)
|
|
75
|
+
(hours * 60) + mins
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# True when these parts could have meant either am or pm — no meridiem
|
|
79
|
+
# given, and an hour small enough that both readings are plausible.
|
|
80
|
+
def ambiguous?(parts) = parts[2].nil? && CLOCK_HOURS.cover?(parts[0].to_i)
|
|
81
|
+
|
|
82
|
+
# The opposite: a bare hour too large to be a 12-hour clock reading, so
|
|
83
|
+
# the writer was plainly using 24-hour notation. TimeRangeParser treats
|
|
84
|
+
# one of these as settling how to read the *other* side of a range.
|
|
85
|
+
def twenty_four_hour?(parts) = parts[2].nil? && !CLOCK_HOURS.cover?(parts[0].to_i)
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def build(date, hours, minutes) = Time.new(date.year, date.month, date.day, hours, minutes, 0)
|
|
90
|
+
|
|
91
|
+
def roll_forward(parts)
|
|
92
|
+
today = @now.to_date
|
|
93
|
+
place(today, *parts) <= @now ? today + 1 : today
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def to_24_hour(hour, minute, meridiem)
|
|
97
|
+
hours = hour.to_i
|
|
98
|
+
minutes = minute.to_i
|
|
99
|
+
raise TimeFormatError, "Invalid minute: #{minute}" if minutes > 59
|
|
100
|
+
return [validate_24_hour(hours), minutes] unless meridiem
|
|
101
|
+
|
|
102
|
+
[apply_meridiem(hours, meridiem), minutes]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_24_hour(hours)
|
|
106
|
+
raise TimeFormatError, "Invalid hour: #{hours}" if hours > 23
|
|
107
|
+
|
|
108
|
+
hours
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply_meridiem(hours, meridiem)
|
|
112
|
+
raise TimeFormatError, "Invalid hour for 12-hour time: #{hours}" if hours.zero? || hours > 12
|
|
113
|
+
|
|
114
|
+
# 12am is hour 0 and 12pm is hour 12, so fold 12 down before shifting.
|
|
115
|
+
base = hours % 12
|
|
116
|
+
meridiem[0].casecmp?('p') ? base + 12 : base
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
module Slk
|
|
7
|
+
module Support
|
|
8
|
+
# Parses a scheduling window ("1:30p-3:30p", "2026-08-04 13:30-15:30")
|
|
9
|
+
# into a [start, end] pair of Unix timestamps.
|
|
10
|
+
#
|
|
11
|
+
# Unlike DateParser, which resolves an input to a point in the past, this
|
|
12
|
+
# always resolves forward: a bare time at or before now rolls to tomorrow,
|
|
13
|
+
# and an end before the start rolls to the next day so overnight windows
|
|
14
|
+
# ("11p-1a") work.
|
|
15
|
+
#
|
|
16
|
+
# The start date is written once and the end can only reach the following
|
|
17
|
+
# day, so this cannot express a multi-day window — `slk status schedule
|
|
18
|
+
# --start/--end` is the general form for that.
|
|
19
|
+
class TimeRangeParser
|
|
20
|
+
RANGE_PATTERN = /\A(?:(\d{4}-\d{2}-\d{2})\s+)?#{TimeParser::TIME}\s*-\s*#{TimeParser::TIME}\z/i
|
|
21
|
+
|
|
22
|
+
EXAMPLE = '1:30p-3:30p or 2026-08-04 13:30-15:30'
|
|
23
|
+
|
|
24
|
+
# Longest overnight window accepted when a reading had to be guessed.
|
|
25
|
+
# Twelve hours is not a policy about window length — an explicit
|
|
26
|
+
# "8p-9a" is longer and fine — it is the point past which a guessed
|
|
27
|
+
# crossing can no longer be what the user meant.
|
|
28
|
+
MAX_GUESSED_OVERNIGHT_MINUTES = 12 * 60
|
|
29
|
+
|
|
30
|
+
# @param input [String] the range to parse
|
|
31
|
+
# @param now [Time] reference point for rolling bare times forward
|
|
32
|
+
# @return [Array(Integer, Integer)] start and end Unix timestamps
|
|
33
|
+
def self.parse(input, now: Time.now) = new(now: now).parse(input)
|
|
34
|
+
|
|
35
|
+
# True when input looks like a time range, used to pick it out of argv.
|
|
36
|
+
def self.match?(input) = RANGE_PATTERN.match?(input.to_s.strip)
|
|
37
|
+
|
|
38
|
+
def initialize(now: Time.now)
|
|
39
|
+
@now = now
|
|
40
|
+
@clock = TimeParser.new(now: now)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse(input)
|
|
44
|
+
match = RANGE_PATTERN.match(input.to_s.strip)
|
|
45
|
+
raise TimeFormatError, "Invalid time range: #{input}. Use #{EXAMPLE}" unless match
|
|
46
|
+
|
|
47
|
+
start_parts, end_parts = infer_meridiems(match)
|
|
48
|
+
validate_range(input, start_parts, end_parts)
|
|
49
|
+
|
|
50
|
+
date = start_date(match, start_parts)
|
|
51
|
+
start_at = @clock.at(date, *start_parts)
|
|
52
|
+
|
|
53
|
+
[start_at.to_i, end_time(date, start_at, end_parts).to_i]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# `match.captures` layout: 0 is the optional date, 1..3 the start time,
|
|
59
|
+
# 4..6 the end. Note the date is `match[1]` in the 1-indexed MatchData form.
|
|
60
|
+
#
|
|
61
|
+
# "1-3p" means 1pm, not 1am: when only one side names a meridiem the
|
|
62
|
+
# other borrows it. The borrow is skipped when it would invert the range
|
|
63
|
+
# ("9-5p" is 9am to 5pm, not 9pm to 5pm).
|
|
64
|
+
def infer_meridiems(match)
|
|
65
|
+
start_parts = match.captures[1..3]
|
|
66
|
+
end_parts = match.captures[4..6]
|
|
67
|
+
|
|
68
|
+
if borrowable?(start_parts, end_parts)
|
|
69
|
+
borrowed = with_meridiem(start_parts, end_parts[2])
|
|
70
|
+
return [borrowed, end_parts] if @clock.minutes(borrowed) < @clock.minutes(end_parts)
|
|
71
|
+
elsif borrowable?(end_parts, start_parts)
|
|
72
|
+
borrowed = with_meridiem(end_parts, start_parts[2])
|
|
73
|
+
return [start_parts, borrowed] if @clock.minutes(start_parts) < @clock.minutes(borrowed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[start_parts, end_parts]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def borrowable?(parts, source) = !source[2].nil? && @clock.ambiguous?(parts)
|
|
80
|
+
|
|
81
|
+
def with_meridiem(parts, meridiem) = [parts[0], parts[1], meridiem]
|
|
82
|
+
|
|
83
|
+
# An explicit date is honoured as given; a bare time at or before now
|
|
84
|
+
# refers to tomorrow.
|
|
85
|
+
def start_date(match, start_parts)
|
|
86
|
+
return @clock.parse_date(match[1], example: EXAMPLE) if match[1]
|
|
87
|
+
|
|
88
|
+
today = @now.to_date
|
|
89
|
+
# `place`, not `at`: this only asks "is that time already past today?",
|
|
90
|
+
# and on a spring-forward date `at` would reject a skipped time outright
|
|
91
|
+
# instead of letting it roll to tomorrow, where it exists.
|
|
92
|
+
@clock.place(today, *start_parts) <= @now ? today + 1 : today
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def end_time(date, start_at, end_parts)
|
|
96
|
+
end_at = @clock.at(date, *end_parts)
|
|
97
|
+
# An end before the start means the window crosses midnight.
|
|
98
|
+
end_at < start_at ? @clock.at(date + 1, *end_parts) : end_at
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_range(input, start_parts, end_parts)
|
|
102
|
+
if @clock.minutes(start_parts) == @clock.minutes(end_parts)
|
|
103
|
+
raise TimeFormatError, "Time range #{input} starts and ends at the same time."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
validate_overnight(input, start_parts, end_parts)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# "9-5" almost always means 9am-5pm, but reads literally as 09:00 to
|
|
110
|
+
# 05:00 the next morning — a 20-hour window nobody asked for. So is
|
|
111
|
+
# "9a-5", where the am/pm is there but on the wrong side to help.
|
|
112
|
+
#
|
|
113
|
+
# Only guessed readings are checked, and only past 12 hours, which is
|
|
114
|
+
# what separates the mistake from the intent. An overnight shift written
|
|
115
|
+
# with a pm start ("9p-5", "10p-2") can only come out 12 hours or less;
|
|
116
|
+
# a dropped meridiem ("9-5", "9a-5", "10-5a") can only come out longer.
|
|
117
|
+
def validate_overnight(input, start_parts, end_parts)
|
|
118
|
+
return unless guessed?(start_parts, end_parts)
|
|
119
|
+
|
|
120
|
+
span = overnight_minutes(start_parts, end_parts)
|
|
121
|
+
return if span.nil? || span <= MAX_GUESSED_OVERNIGHT_MINUTES
|
|
122
|
+
|
|
123
|
+
raise TimeFormatError,
|
|
124
|
+
"Time range #{input} reads as crossing midnight and spans #{format_span(span)}. " \
|
|
125
|
+
'Add am/pm to both sides (9a-5p), use 24-hour times (9:00-17:00), ' \
|
|
126
|
+
'or --start/--end for a multi-day window.'
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# True when a side's reading had to be guessed.
|
|
130
|
+
#
|
|
131
|
+
# A leftover ambiguous side is not enough on its own: in "20:00-6" the
|
|
132
|
+
# 24-hour start settles the whole range, so "6" is 6am and was not
|
|
133
|
+
# guessed. Without such an anchor, any side still bare after
|
|
134
|
+
# `infer_meridiems` was read as 24-hour for want of anything better —
|
|
135
|
+
# either because no meridiem was given at all ("9-5"), or because
|
|
136
|
+
# borrowing the one that was given would have inverted the range
|
|
137
|
+
# ("9a-5", where 5am precedes 9am).
|
|
138
|
+
def guessed?(start_parts, end_parts)
|
|
139
|
+
return false if @clock.twenty_four_hour?(start_parts) || @clock.twenty_four_hour?(end_parts)
|
|
140
|
+
|
|
141
|
+
@clock.ambiguous?(start_parts) || @clock.ambiguous?(end_parts)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Wall-clock minutes from start to end across midnight, or nil when the
|
|
145
|
+
# window stays on one date.
|
|
146
|
+
#
|
|
147
|
+
# Deliberately not `end_at - start_at`: elapsed seconds stretch or shrink
|
|
148
|
+
# by an hour across a DST boundary, so the same text would report a
|
|
149
|
+
# different span depending on the date it happened to land on.
|
|
150
|
+
def overnight_minutes(start_parts, end_parts)
|
|
151
|
+
start_minutes = @clock.minutes(start_parts)
|
|
152
|
+
end_minutes = @clock.minutes(end_parts)
|
|
153
|
+
return nil if end_minutes > start_minutes
|
|
154
|
+
|
|
155
|
+
TimeParser::MINUTES_PER_DAY - start_minutes + end_minutes
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Whole hours read better, but rounding them would report a 12h01m window
|
|
159
|
+
# as "12 hours" against a 12-hour limit.
|
|
160
|
+
def format_span(minutes)
|
|
161
|
+
hours, mins = minutes.divmod(60)
|
|
162
|
+
mins.zero? ? "#{hours} hours" : format('%<h>dh%<m>02dm', h: hours, m: mins)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -12,6 +12,17 @@ require 'io/console'
|
|
|
12
12
|
module Slk
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
|
+
# The invocation itself was wrong (a flag with no value, an unparseable
|
|
16
|
+
# time). The message is written for the user and is printed verbatim, with
|
|
17
|
+
# no "Error type:" label, because there is no internal fault to report.
|
|
18
|
+
class UsageError < Error; end
|
|
19
|
+
|
|
20
|
+
# A time or range the user typed that cannot be understood. Separate from
|
|
21
|
+
# UsageError so callers can rescue *only* malformed input: a blanket
|
|
22
|
+
# `rescue ArgumentError` around a parse call also swallows arity and range
|
|
23
|
+
# errors from the code itself and presents them as if the user mistyped.
|
|
24
|
+
class TimeFormatError < UsageError; end
|
|
25
|
+
|
|
15
26
|
# Errors from any Slack-API-shaped failure: HTTP errors, network errors,
|
|
16
27
|
# logical Slack errors (user_not_found, missing_scope, etc.), JSON parse
|
|
17
28
|
# failures. The optional `code` symbol lets callers match specific cases
|
|
@@ -55,6 +66,9 @@ module Slk
|
|
|
55
66
|
autoload :Duration, 'slk/models/duration'
|
|
56
67
|
autoload :Workspace, 'slk/models/workspace'
|
|
57
68
|
autoload :Status, 'slk/models/status'
|
|
69
|
+
autoload :ScheduledStatus, 'slk/models/scheduled_status'
|
|
70
|
+
autoload :DndState, 'slk/models/dnd_state'
|
|
71
|
+
autoload :StatusSnapshot, 'slk/models/status_snapshot'
|
|
58
72
|
autoload :Message, 'slk/models/message'
|
|
59
73
|
autoload :Reaction, 'slk/models/reaction'
|
|
60
74
|
autoload :User, 'slk/models/user'
|
|
@@ -105,6 +119,7 @@ module Slk
|
|
|
105
119
|
autoload :MessageFormatter, 'slk/formatters/message_formatter'
|
|
106
120
|
autoload :ReactionFormatter, 'slk/formatters/reaction_formatter'
|
|
107
121
|
autoload :JsonMessageFormatter, 'slk/formatters/json_message_formatter'
|
|
122
|
+
autoload :JsonStatusFormatter, 'slk/formatters/json_status_formatter'
|
|
108
123
|
autoload :ActivityFormatter, 'slk/formatters/activity_formatter'
|
|
109
124
|
autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
|
|
110
125
|
autoload :BlockFormatter, 'slk/formatters/block_formatter'
|
|
@@ -153,6 +168,7 @@ module Slk
|
|
|
153
168
|
autoload :Activity, 'slk/api/activity'
|
|
154
169
|
autoload :Search, 'slk/api/search'
|
|
155
170
|
autoload :Saved, 'slk/api/saved'
|
|
171
|
+
autoload :CustomStatus, 'slk/api/custom_status'
|
|
156
172
|
autoload :Team, 'slk/api/team'
|
|
157
173
|
end
|
|
158
174
|
|
|
@@ -167,5 +183,7 @@ module Slk
|
|
|
167
183
|
autoload :TextWrapper, 'slk/support/text_wrapper'
|
|
168
184
|
autoload :InteractivePrompt, 'slk/support/interactive_prompt'
|
|
169
185
|
autoload :DateParser, 'slk/support/date_parser'
|
|
186
|
+
autoload :TimeParser, 'slk/support/time_parser'
|
|
187
|
+
autoload :TimeRangeParser, 'slk/support/time_range_parser'
|
|
170
188
|
end
|
|
171
189
|
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.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Boehs
|
|
@@ -30,6 +30,7 @@ files:
|
|
|
30
30
|
- lib/slk/api/bots.rb
|
|
31
31
|
- lib/slk/api/client.rb
|
|
32
32
|
- lib/slk/api/conversations.rb
|
|
33
|
+
- lib/slk/api/custom_status.rb
|
|
33
34
|
- lib/slk/api/dnd.rb
|
|
34
35
|
- lib/slk/api/emoji.rb
|
|
35
36
|
- lib/slk/api/saved.rb
|
|
@@ -66,6 +67,7 @@ files:
|
|
|
66
67
|
- lib/slk/formatters/duration_formatter.rb
|
|
67
68
|
- lib/slk/formatters/emoji_replacer.rb
|
|
68
69
|
- lib/slk/formatters/json_message_formatter.rb
|
|
70
|
+
- lib/slk/formatters/json_status_formatter.rb
|
|
69
71
|
- lib/slk/formatters/markdown_output.rb
|
|
70
72
|
- lib/slk/formatters/mention_replacer.rb
|
|
71
73
|
- lib/slk/formatters/message_formatter.rb
|
|
@@ -78,6 +80,7 @@ files:
|
|
|
78
80
|
- lib/slk/formatters/search_formatter.rb
|
|
79
81
|
- lib/slk/formatters/text_processor.rb
|
|
80
82
|
- lib/slk/models/channel.rb
|
|
83
|
+
- lib/slk/models/dnd_state.rb
|
|
81
84
|
- lib/slk/models/duration.rb
|
|
82
85
|
- lib/slk/models/message.rb
|
|
83
86
|
- lib/slk/models/preset.rb
|
|
@@ -85,8 +88,10 @@ files:
|
|
|
85
88
|
- lib/slk/models/profile_field.rb
|
|
86
89
|
- lib/slk/models/reaction.rb
|
|
87
90
|
- lib/slk/models/saved_item.rb
|
|
91
|
+
- lib/slk/models/scheduled_status.rb
|
|
88
92
|
- lib/slk/models/search_result.rb
|
|
89
93
|
- lib/slk/models/status.rb
|
|
94
|
+
- lib/slk/models/status_snapshot.rb
|
|
90
95
|
- lib/slk/models/user.rb
|
|
91
96
|
- lib/slk/models/workspace.rb
|
|
92
97
|
- lib/slk/runner.rb
|
|
@@ -123,6 +128,8 @@ files:
|
|
|
123
128
|
- lib/slk/support/platform.rb
|
|
124
129
|
- lib/slk/support/slack_url_parser.rb
|
|
125
130
|
- lib/slk/support/text_wrapper.rb
|
|
131
|
+
- lib/slk/support/time_parser.rb
|
|
132
|
+
- lib/slk/support/time_range_parser.rb
|
|
126
133
|
- lib/slk/support/user_resolver.rb
|
|
127
134
|
- lib/slk/support/xdg_paths.rb
|
|
128
135
|
- lib/slk/version.rb
|
|
@@ -148,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
148
155
|
- !ruby/object:Gem::Version
|
|
149
156
|
version: '0'
|
|
150
157
|
requirements: []
|
|
151
|
-
rubygems_version: 4.0.
|
|
158
|
+
rubygems_version: 4.0.16
|
|
152
159
|
specification_version: 4
|
|
153
160
|
summary: A command-line interface for Slack
|
|
154
161
|
test_files: []
|