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.
@@ -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
- return nil unless @api
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end