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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Orchestrates the API calls needed to assemble a Models::Profile.
|
|
6
|
+
# Memoizes within the instance — one resolver per command run.
|
|
7
|
+
class ProfileResolver
|
|
8
|
+
SCHEMA_TTL = 86_400 # 24h
|
|
9
|
+
PROFILE_TTL = 3_600 # 1h
|
|
10
|
+
EMPTY_SCHEMA = { 'ok' => false, 'profile' => { 'fields' => [], 'sections' => [] } }.freeze
|
|
11
|
+
|
|
12
|
+
attr_accessor :refresh
|
|
13
|
+
|
|
14
|
+
def initialize(users_api:, team_api:, cache_store: nil, workspace_name: nil, on_debug: nil)
|
|
15
|
+
@users_api = users_api
|
|
16
|
+
@team_api = team_api
|
|
17
|
+
@cache_store = cache_store
|
|
18
|
+
@workspace_name = workspace_name
|
|
19
|
+
@on_debug = on_debug
|
|
20
|
+
@refresh = false
|
|
21
|
+
@profile_cache = {}
|
|
22
|
+
@home_team_names = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve a user ID to a Profile. Memoized per resolver instance.
|
|
26
|
+
def resolve(user_id)
|
|
27
|
+
return @profile_cache[user_id] if @profile_cache.key?(user_id)
|
|
28
|
+
|
|
29
|
+
@profile_cache[user_id] = build_profile(user_id)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Resolve a profile and one level of type:user custom fields, populating
|
|
33
|
+
# `resolved_users` so the formatter can render the People section.
|
|
34
|
+
def resolve_with_people(user_id)
|
|
35
|
+
profile = resolve(user_id)
|
|
36
|
+
return profile if profile.external?
|
|
37
|
+
|
|
38
|
+
profile.people_fields.flat_map(&:user_ids).uniq.each do |ref_id|
|
|
39
|
+
profile.resolved_users[ref_id] ||= resolve(ref_id)
|
|
40
|
+
end
|
|
41
|
+
profile
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Walks Supervisor (or first non-inverse type:user field) upward.
|
|
45
|
+
# Returns Array<Profile> from immediate supervisor to top, capped by depth.
|
|
46
|
+
def resolve_chain_up(user_id, depth: 5)
|
|
47
|
+
chain = []
|
|
48
|
+
seen = Set.new([user_id])
|
|
49
|
+
current = resolve(user_id)
|
|
50
|
+
depth.times { current = step_up(current, seen, chain) or break }
|
|
51
|
+
chain
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def step_up(current, seen, chain)
|
|
57
|
+
parent_id = current.supervisor_ids.first
|
|
58
|
+
return nil unless parent_id && !seen.include?(parent_id)
|
|
59
|
+
|
|
60
|
+
parent = resolve(parent_id)
|
|
61
|
+
chain << parent
|
|
62
|
+
seen << parent.user_id
|
|
63
|
+
parent
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_profile(user_id)
|
|
67
|
+
profile = ProfileBuilder.build(
|
|
68
|
+
profile_response: fetch_profile_response(user_id),
|
|
69
|
+
info_response: cache_or_fetch("ui_#{user_id}", ttl: PROFILE_TTL) { @users_api.info(user_id) },
|
|
70
|
+
schema_response: schema,
|
|
71
|
+
workspace_team_id: workspace_team_id
|
|
72
|
+
)
|
|
73
|
+
attach_extras(profile, user_id)
|
|
74
|
+
rescue ApiError => e
|
|
75
|
+
@on_debug&.call("Profile resolve failed for #{user_id}: #{e.message}")
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Only swallow `user_not_found` (Slack Connect); other errors propagate.
|
|
80
|
+
def fetch_profile_response(user_id)
|
|
81
|
+
key = "up_#{user_id}"
|
|
82
|
+
cached = MetaCache.read(@cache_store, @workspace_name, key, ttl: PROFILE_TTL) unless @refresh
|
|
83
|
+
return cached if cached
|
|
84
|
+
|
|
85
|
+
response = @users_api.profile_for(user_id)
|
|
86
|
+
MetaCache.write(@cache_store, @workspace_name, key, response)
|
|
87
|
+
response
|
|
88
|
+
rescue ApiError => e
|
|
89
|
+
raise unless e.code == :user_not_found
|
|
90
|
+
|
|
91
|
+
@on_debug&.call("#{key}: #{e.message} (falling back to users.info)")
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def attach_extras(profile, user_id)
|
|
96
|
+
profile = attach_home_team_name(profile)
|
|
97
|
+
presence = fetch_presence(user_id)
|
|
98
|
+
presence ? Models::Profile.new(**profile.to_h, presence: presence) : profile
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fetch_presence(user_id)
|
|
102
|
+
@users_api.get_presence_for(user_id)&.dig('presence')
|
|
103
|
+
rescue ApiError => e
|
|
104
|
+
@on_debug&.call("get_presence_for(#{user_id}) failed: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def schema
|
|
109
|
+
@schema ||= cache_or_fetch('team_profile_schema', ttl: SCHEMA_TTL,
|
|
110
|
+
empty: EMPTY_SCHEMA) { @team_api.profile_schema }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def workspace_team_id
|
|
114
|
+
@workspace_team_id ||= cache_or_fetch('workspace_team_id') { @team_api.info.dig('team', 'id') }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def cache_or_fetch(key, ttl: nil, empty: nil, &)
|
|
118
|
+
MetaCache.fetch(@cache_store, @workspace_name, key, ttl: ttl, refresh: @refresh, &)
|
|
119
|
+
rescue ApiError => e
|
|
120
|
+
@on_debug&.call("#{key} fetch failed: #{e.message}")
|
|
121
|
+
empty
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def attach_home_team_name(profile)
|
|
125
|
+
return profile unless profile.external? && profile.team_id && (name = home_team_name(profile.team_id))
|
|
126
|
+
|
|
127
|
+
Models::Profile.new(**profile.to_h, home_team_name: name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def home_team_name(team_id)
|
|
131
|
+
@home_team_names[team_id] ||= @team_api.info(team_id).dig('team', 'name')
|
|
132
|
+
rescue ApiError => e
|
|
133
|
+
@on_debug&.call("team.info(#{team_id}) failed: #{e.message}")
|
|
134
|
+
@home_team_names[team_id] = nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -52,6 +52,14 @@ module Slk
|
|
|
52
52
|
fetch_id_by_name(name)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# @return [Array<Hash>] raw users.list-shaped hashes for all matches
|
|
56
|
+
def find_all_by_name(name)
|
|
57
|
+
UserMatcher.new(
|
|
58
|
+
api_client: @api, workspace: @workspace,
|
|
59
|
+
cache_store: @cache, on_debug: @on_debug
|
|
60
|
+
).find_all(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
55
63
|
private
|
|
56
64
|
|
|
57
65
|
def fetch_and_cache_name(user_id)
|
|
@@ -88,11 +96,7 @@ module Slk
|
|
|
88
96
|
end
|
|
89
97
|
|
|
90
98
|
def fetch_id_by_name(name)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
|
|
94
|
-
users = users_api.list['members'] || []
|
|
95
|
-
user = find_user_by_name(users, name)
|
|
99
|
+
user = find_all_by_name(name).first
|
|
96
100
|
cache_user_from_api(user) if user
|
|
97
101
|
user&.dig('id')
|
|
98
102
|
rescue ApiError => e
|
|
@@ -100,14 +104,6 @@ module Slk
|
|
|
100
104
|
nil
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
def find_user_by_name(users, name)
|
|
104
|
-
users.find do |u|
|
|
105
|
-
u['name'] == name ||
|
|
106
|
-
u.dig('profile', 'display_name') == name ||
|
|
107
|
-
u.dig('profile', 'real_name') == name
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
107
|
def cache_user_from_api(user_data)
|
|
112
108
|
user = Models::User.from_api(user_data)
|
|
113
109
|
@cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Finds all users whose name/display/real/first+last matches a query
|
|
6
|
+
# (case-insensitive). Combines users.list with previously-resolved profiles
|
|
7
|
+
# cached locally — Slack Connect external users don't appear in users.list
|
|
8
|
+
# but do show up in the meta cache once users.info has been fetched.
|
|
9
|
+
class UserMatcher
|
|
10
|
+
def initialize(api_client:, workspace:, cache_store:, on_debug: nil)
|
|
11
|
+
@api = api_client
|
|
12
|
+
@workspace = workspace
|
|
13
|
+
@cache = cache_store
|
|
14
|
+
@on_debug = on_debug
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns users.list-shaped hashes (deduped by id). Raises ApiError on
|
|
18
|
+
# network/auth failures so callers don't conflate them with "no matches".
|
|
19
|
+
def find_all(name)
|
|
20
|
+
return [] if name.to_s.empty? || @api.nil?
|
|
21
|
+
|
|
22
|
+
target = name.downcase
|
|
23
|
+
candidates = list_members + cached_profile_users
|
|
24
|
+
unique_by_id(candidates.select { |u| matches?(u, target) })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def matches?(user, target_lower)
|
|
28
|
+
name_candidates(user).any? { |c| c.downcase == target_lower }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def name_candidates(user)
|
|
32
|
+
profile = user['profile'] || {}
|
|
33
|
+
full = [profile['first_name'], profile['last_name']].compact.join(' ').strip
|
|
34
|
+
[user['name'], profile['display_name'], profile['real_name'], full]
|
|
35
|
+
.map(&:to_s).reject(&:empty?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def list_members
|
|
41
|
+
Api::Users.new(@api, @workspace, on_debug: @on_debug).list['members'] || []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reshape cached `ui_<uid>` meta entries (raw users.info responses) into
|
|
45
|
+
# users.list-shaped hashes so the matcher can compare them uniformly.
|
|
46
|
+
def cached_profile_users
|
|
47
|
+
return [] unless @cache.respond_to?(:each_meta)
|
|
48
|
+
|
|
49
|
+
@cache.each_meta(@workspace.name).filter_map do |key, value|
|
|
50
|
+
next unless key.start_with?('ui_')
|
|
51
|
+
|
|
52
|
+
user = value.is_a?(Hash) ? value.dig('value', 'user') : nil
|
|
53
|
+
user if user.is_a?(Hash) && user['id']
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unique_by_id(users)
|
|
58
|
+
seen = {}
|
|
59
|
+
users.each { |u| seen[u['id']] ||= u }
|
|
60
|
+
seen.values
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Disambiguates between multiple matching users. Prompts at a TTY; raises
|
|
6
|
+
# ApiError in non-interactive contexts so callers don't silently get the
|
|
7
|
+
# wrong user when name resolution is ambiguous.
|
|
8
|
+
class UserPicker
|
|
9
|
+
def initialize(stdin: $stdin, prompt_io: $stderr)
|
|
10
|
+
@stdin = stdin
|
|
11
|
+
@prompt_io = prompt_io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pick(matches)
|
|
15
|
+
return matches.first['id'] if matches.size == 1
|
|
16
|
+
|
|
17
|
+
unless interactive?
|
|
18
|
+
raise ApiError,
|
|
19
|
+
"Ambiguous match (#{matches.size} users): #{ids(matches).join(', ')}. " \
|
|
20
|
+
'Use --pick N or --all to disambiguate non-interactively.'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
list(matches)
|
|
24
|
+
matches[read_index(matches.size)]['id']
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def interactive?
|
|
30
|
+
@stdin.respond_to?(:tty?) && @stdin.tty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def list(matches)
|
|
34
|
+
@prompt_io.puts('Multiple users match — pick one:')
|
|
35
|
+
matches.each_with_index { |u, i| @prompt_io.puts(" [#{i + 1}] #{describe(u)}") }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def describe(user)
|
|
39
|
+
profile = user['profile'] || {}
|
|
40
|
+
name = profile['real_name'] || profile['display_name'] || user['name']
|
|
41
|
+
suffix = profile['title'].to_s.empty? ? '' : " — #{profile['title']}"
|
|
42
|
+
"#{name} (#{user['id']})#{suffix}#{flag_suffix(user)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def flag_suffix(user)
|
|
46
|
+
flags = []
|
|
47
|
+
flags << 'deactivated' if user['deleted']
|
|
48
|
+
flags << 'bot' if user['is_bot']
|
|
49
|
+
flags.empty? ? '' : " [#{flags.join(', ')}]"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ids(matches)
|
|
53
|
+
matches.map { |u| u['id'] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_index(count)
|
|
57
|
+
loop do
|
|
58
|
+
@prompt_io.print("Choice [1-#{count}]: ")
|
|
59
|
+
choice = @stdin.gets&.strip
|
|
60
|
+
raise ApiError, 'No selection made' if choice.nil? || choice.empty?
|
|
61
|
+
|
|
62
|
+
n = Integer(choice, exception: false)
|
|
63
|
+
return n - 1 if n&.between?(1, count)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Resolves the positional target for `slk who` into one or more user_ids.
|
|
6
|
+
class WhoTargetResolver
|
|
7
|
+
def initialize(workspace:, cache_store:, api_client:, output:, options:)
|
|
8
|
+
@workspace = workspace
|
|
9
|
+
@cache_store = cache_store
|
|
10
|
+
@api_client = api_client
|
|
11
|
+
@output = output
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve(target)
|
|
16
|
+
return [self_user_id] if target.nil? || target == 'me'
|
|
17
|
+
return [target] if target.match?(/\A[UW][A-Z0-9]+\z/)
|
|
18
|
+
|
|
19
|
+
ids = resolve_by_name(target.delete_prefix('@'))
|
|
20
|
+
ids || (raise ApiError, "Could not resolve user: #{target}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_by_name(name)
|
|
26
|
+
matches = lookup.find_all_by_name(name)
|
|
27
|
+
return select(matches) if matches.any?
|
|
28
|
+
|
|
29
|
+
cached = @cache_store.get_user_id_by_name(@workspace.name, name)
|
|
30
|
+
cached ? [cached] : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def select(matches)
|
|
34
|
+
return matches.map { |u| u['id'] } if @options[:all]
|
|
35
|
+
return [pick_by_index(matches)] if @options[:pick]
|
|
36
|
+
|
|
37
|
+
[UserPicker.new.pick(matches)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pick_by_index(matches)
|
|
41
|
+
idx = @options[:pick]
|
|
42
|
+
raise ApiError, "--pick #{idx} out of range (got #{matches.size} matches)" unless idx&.between?(1, matches.size)
|
|
43
|
+
|
|
44
|
+
matches[idx - 1]['id']
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lookup
|
|
48
|
+
UserLookup.new(
|
|
49
|
+
cache_store: @cache_store, workspace: @workspace,
|
|
50
|
+
api_client: @api_client, on_debug: ->(msg) { @output.debug(msg) }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self_user_id
|
|
55
|
+
cached = @cache_store.get_meta(@workspace.name, 'self_user_id')
|
|
56
|
+
return cached if cached
|
|
57
|
+
|
|
58
|
+
user_id = Api::Client.new(@api_client, @workspace).auth_test['user_id']
|
|
59
|
+
@cache_store.set_meta(@workspace.name, 'self_user_id', user_id) if user_id
|
|
60
|
+
user_id
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -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