slk 0.4.0 → 0.4.2

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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Markdown output adapter with same interface as Output
6
+ # Produces markdown formatting instead of ANSI colors
7
+ class MarkdownOutput
8
+ attr_reader :verbose, :quiet
9
+
10
+ # Accept color: parameter for interface compatibility with Output (ignored for markdown)
11
+ def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false) # rubocop:disable Lint/UnusedMethodArgument
12
+ @io = io
13
+ @err = err
14
+ @verbose = verbose
15
+ @quiet = quiet
16
+ end
17
+
18
+ def puts(message = '')
19
+ @io.puts(message) unless @quiet
20
+ end
21
+
22
+ def print(message)
23
+ @io.print(message) unless @quiet
24
+ end
25
+
26
+ def error(message)
27
+ @err.puts("**Error:** #{message}")
28
+ end
29
+
30
+ def warn(message)
31
+ @err.puts("*Warning:* #{message}") unless @quiet
32
+ end
33
+
34
+ def success(message)
35
+ puts("✓ #{message}")
36
+ end
37
+
38
+ def info(message)
39
+ puts(message)
40
+ end
41
+
42
+ def debug(message)
43
+ return unless @verbose
44
+
45
+ @err.puts("*[debug]* #{message}")
46
+ end
47
+
48
+ # Markdown formatting helpers - string interpolation handles nil conversion
49
+ # bold -> **text**
50
+ def bold(text)
51
+ "**#{text}**"
52
+ end
53
+
54
+ # red -> **text** (emphasis for errors)
55
+ def red(text)
56
+ "**#{text}**"
57
+ end
58
+
59
+ # green -> plain text (success doesn't need markup)
60
+ def green(text)
61
+ text.to_s
62
+ end
63
+
64
+ # yellow -> *text* (italics for warnings)
65
+ def yellow(text)
66
+ "*#{text}*"
67
+ end
68
+
69
+ # blue -> `text` (code for timestamps)
70
+ def blue(text)
71
+ "`#{text}`"
72
+ end
73
+
74
+ # magenta -> *text* (italics for secondary)
75
+ def magenta(text)
76
+ "*#{text}*"
77
+ end
78
+
79
+ # cyan -> `text` (code for metadata like timestamps)
80
+ def cyan(text)
81
+ "`#{text}`"
82
+ end
83
+
84
+ # gray -> *text* (italics for secondary info)
85
+ def gray(text)
86
+ "*#{text}*"
87
+ end
88
+
89
+ def with_verbose(value)
90
+ self.class.new(io: @io, err: @err, verbose: value, quiet: @quiet)
91
+ end
92
+
93
+ def with_quiet(value)
94
+ self.class.new(io: @io, err: @err, verbose: @verbose, quiet: value)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -6,13 +6,16 @@ module Slk
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class MessageFormatter
8
8
  # rubocop:disable Metrics/ParameterLists
9
- def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil, on_debug: nil)
9
+ def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil,
10
+ text_processor: nil, on_debug: nil)
10
11
  @output = output
11
- @mentions = mention_replacer
12
- @emoji = emoji_replacer
13
12
  @cache = cache_store
14
13
  @api_client = api_client
15
14
  @on_debug = on_debug
15
+ @text_processor = text_processor || TextProcessor.new(
16
+ mention_replacer: mention_replacer,
17
+ emoji_replacer: emoji_replacer
18
+ )
16
19
  @reaction_formatter = build_reaction_formatter(output, emoji_replacer, cache_store)
17
20
  @json_formatter = JsonMessageFormatter.new(cache_store: cache_store)
18
21
  end
@@ -25,7 +28,7 @@ module Slk
25
28
  def format(message, workspace:, options: {})
26
29
  username = resolve_username(message, workspace, options)
27
30
  timestamp = format_timestamp(message.timestamp)
28
- text = process_text(message.text, workspace, options)
31
+ text = @text_processor.process(message.text, workspace, options)
29
32
 
30
33
  header = build_header(timestamp, username)
31
34
  display_text = build_display_text(text, message, header, options)
@@ -37,7 +40,7 @@ module Slk
37
40
  def format_simple(message, workspace:, options: {})
38
41
  username = resolve_username(message, workspace, options)
39
42
  timestamp = format_timestamp(message.timestamp)
40
- text = process_text(message.text, workspace, options)
43
+ text = @text_processor.process(message.text, workspace, options)
41
44
 
42
45
  reaction_text = ''
43
46
  unless options[:no_reactions] || message.reactions.empty?
@@ -88,7 +91,7 @@ module Slk
88
91
 
89
92
  def build_output_lines(main_line, message, workspace, options, display_text)
90
93
  lines = [main_line]
91
- text_processor = ->(txt) { process_text(txt, workspace, options) }
94
+ text_processor = ->(txt) { @text_processor.process(txt, workspace, options) }
92
95
 
93
96
  BlockFormatter.new(text_processor: text_processor)
94
97
  .format(message.blocks, message.text, lines, options)
@@ -118,18 +121,7 @@ module Slk
118
121
  end
119
122
 
120
123
  def format_timestamp(time)
121
- time.strftime('%Y-%m-%d %H:%M')
122
- end
123
-
124
- def process_text(text, workspace, options)
125
- result = decode_html_entities(text.dup)
126
- result = @mentions.replace(result, workspace)
127
- result = @emoji.replace(result, workspace) unless options[:no_emoji]
128
- result
129
- end
130
-
131
- def decode_html_entities(text)
132
- text.gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>')
124
+ time.strftime('%Y-%m-%d %H:%M:%S')
133
125
  end
134
126
 
135
127
  def format_files(message, lines, options, skip_first: false)
@@ -80,7 +80,7 @@ module Slk
80
80
 
81
81
  def format_time(slack_timestamp)
82
82
  time = Time.at(slack_timestamp.to_f)
83
- time.strftime('%-I:%M %p') # e.g., "2:45 PM"
83
+ time.strftime('%-I:%M:%S %p') # e.g., "2:45:30 PM"
84
84
  end
85
85
  end
86
86
  end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Formats saved/later items for terminal display
6
+ class SavedItemFormatter
7
+ def initialize(output:, mention_replacer:, text_processor:, on_debug: nil)
8
+ @output = output
9
+ @mentions = mention_replacer
10
+ @text_processor = text_processor
11
+ @on_debug = on_debug
12
+ end
13
+
14
+ # Display a single saved item
15
+ # @param truncate [Boolean] if true, truncate to single line at width instead of wrapping
16
+ def display_item(item, workspace, message: nil, width: nil, truncate: false)
17
+ status_badge = format_status_badge(item)
18
+ due_info = format_due_info(item)
19
+
20
+ # First line: status badge and due info
21
+ header_parts = [status_badge, due_info].compact.reject(&:empty?)
22
+ @output.puts header_parts.join(' | ') unless header_parts.empty?
23
+
24
+ # Message content
25
+ display_message(message, workspace, width: width, truncate: truncate) if message
26
+ @output.puts # blank line between items
27
+ end
28
+
29
+ private
30
+
31
+ # rubocop:disable Metrics/MethodLength
32
+ def format_status_badge(item)
33
+ badge = case item.state
34
+ when 'completed' then '[completed]'
35
+ when 'in_progress' then '[in_progress]'
36
+ when 'saved' then '[saved]'
37
+ else "[#{item.state}]"
38
+ end
39
+
40
+ # Color based on overdue status
41
+ if item.overdue?
42
+ @output.red(badge)
43
+ elsif item.state == 'completed'
44
+ @output.green(badge)
45
+ elsif item.state == 'in_progress'
46
+ @output.yellow(badge)
47
+ else
48
+ @output.blue(badge)
49
+ end
50
+ end
51
+
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def format_due_info(item)
55
+ return '' unless item.due_date?
56
+
57
+ time_diff = item.time_until_due
58
+ formatted = format_time_difference(time_diff)
59
+
60
+ if item.overdue?
61
+ "Due: #{@output.red(formatted)}"
62
+ else
63
+ "Due: #{formatted}"
64
+ end
65
+ end
66
+
67
+ # rubocop:disable Metrics/MethodLength
68
+ def format_time_difference(seconds)
69
+ abs_seconds = seconds.abs
70
+ ago = seconds.negative?
71
+
72
+ formatted = if abs_seconds < 60
73
+ "#{abs_seconds}s"
74
+ elsif abs_seconds < 3600
75
+ "#{abs_seconds / 60}m"
76
+ elsif abs_seconds < 86_400
77
+ "#{abs_seconds / 3600}h"
78
+ else
79
+ "#{abs_seconds / 86_400}d"
80
+ end
81
+
82
+ ago ? "#{formatted} ago" : "in #{formatted}"
83
+ end
84
+ # rubocop:enable Metrics/MethodLength
85
+
86
+ def display_message(message, workspace, width: nil, truncate: false)
87
+ username = resolve_message_author(message, workspace)
88
+ text = prepare_message_text(message, workspace)
89
+ header = " #{@output.bold(username)}: "
90
+ header_width = Support::TextWrapper.visible_length(header)
91
+
92
+ if truncate
93
+ display_truncated_message(header, text, width, header_width)
94
+ else
95
+ display_wrapped_message(header, text, width, header_width)
96
+ end
97
+ end
98
+
99
+ def display_truncated_message(header, text, width, header_width)
100
+ # Single line, truncated at width
101
+ first_line = text.lines.first&.strip || text
102
+ max_text_width = width ? [width - header_width, 10].max : 100
103
+ @output.puts "#{header}#{truncate_text(first_line, max_text_width)}"
104
+ end
105
+
106
+ def display_wrapped_message(header, text, width, header_width)
107
+ # Full message with wrapping
108
+ wrapped = wrap_text(text, header_width, width)
109
+ lines = wrapped.lines
110
+
111
+ first_line = lines.first&.rstrip || wrapped
112
+ @output.puts "#{header}#{first_line}"
113
+ lines[1..].each { |line| @output.puts " #{line.rstrip}" } if lines.length > 1
114
+ end
115
+
116
+ def resolve_message_author(message, workspace)
117
+ if message['user']
118
+ @mentions.lookup_user_name(workspace, message['user']) || message['user']
119
+ elsif message['bot_id']
120
+ 'Bot'
121
+ else
122
+ 'Unknown'
123
+ end
124
+ end
125
+
126
+ def prepare_message_text(message, workspace)
127
+ @text_processor.process(message['text'], workspace)
128
+ end
129
+
130
+ def wrap_text(text, indent_width, width)
131
+ return text if text.empty? || !width
132
+
133
+ # If header is wider than target, first line gets no wrap, subsequent lines wrap at width - 2
134
+ first_line_width = [width - indent_width, 10].max
135
+ continuation_width = [width - 2, 10].max
136
+ Support::TextWrapper.wrap(text, first_line_width, continuation_width)
137
+ end
138
+
139
+ def truncate_text(text, max_length)
140
+ text.length > max_length ? "#{text[0...max_length]}..." : text
141
+ end
142
+ end
143
+ end
144
+ end
@@ -4,10 +4,10 @@ module Slk
4
4
  module Formatters
5
5
  # Formats search results for terminal display
6
6
  class SearchFormatter
7
- def initialize(output:, emoji_replacer:, mention_replacer:)
7
+ def initialize(output:, mention_replacer:, text_processor:)
8
8
  @output = output
9
- @emoji = emoji_replacer
10
9
  @mentions = mention_replacer
10
+ @text_processor = text_processor
11
11
  end
12
12
 
13
13
  # Display a list of search results
@@ -54,11 +54,7 @@ module Slk
54
54
  end
55
55
 
56
56
  def prepare_text(text, workspace, options)
57
- return text if options[:no_emoji] && options[:no_mentions]
58
-
59
- text = @emoji.replace(text) unless options[:no_emoji]
60
- text = @mentions.replace(text, workspace) unless options[:no_mentions]
61
- text
57
+ @text_processor.process(text, workspace, options)
62
58
  end
63
59
 
64
60
  def display_files(files)
@@ -68,7 +64,7 @@ module Slk
68
64
  end
69
65
 
70
66
  def format_time(time)
71
- time.strftime('%Y-%m-%d %H:%M')
67
+ time.strftime('%Y-%m-%d %H:%M:%S')
72
68
  end
73
69
  end
74
70
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Centralized text processing for message display
6
+ # Handles HTML entity decoding, mention replacement, and emoji replacement
7
+ class TextProcessor
8
+ def initialize(mention_replacer:, emoji_replacer:)
9
+ @mentions = mention_replacer
10
+ @emoji = emoji_replacer
11
+ end
12
+
13
+ # Process raw Slack message text for display
14
+ # @param text [String] Raw message text from Slack API
15
+ # @param workspace [Models::Workspace] The workspace for name resolution
16
+ # @param options [Hash] Processing options
17
+ # @option options [Boolean] :no_emoji Skip emoji replacement
18
+ # @option options [Boolean] :no_mentions Skip mention replacement
19
+ # @return [String] Processed text ready for display
20
+ def process(text, workspace, options = {})
21
+ return '[No text]' if text.to_s.empty?
22
+
23
+ result = decode_html_entities(text.dup)
24
+ result = safe_replace_mentions(result, workspace) unless options[:no_mentions]
25
+ result = safe_replace_emoji(result, workspace) unless options[:no_emoji]
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def decode_html_entities(text)
32
+ text.gsub('&amp;', '&').gsub('&lt;', '<').gsub('&gt;', '>')
33
+ end
34
+
35
+ def safe_replace_mentions(text, workspace)
36
+ @mentions.replace(text, workspace)
37
+ rescue StandardError
38
+ text
39
+ end
40
+
41
+ def safe_replace_emoji(text, workspace)
42
+ @emoji.replace(text, workspace)
43
+ rescue StandardError
44
+ text
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # State constants for saved items
6
+ SAVED_STATE_SAVED = 'saved'
7
+ SAVED_STATE_IN_PROGRESS = 'in_progress'
8
+ SAVED_STATE_COMPLETED = 'completed'
9
+
10
+ # Represents a saved/later item from Slack's saved.list API
11
+ # rubocop:disable Metrics/ParameterLists
12
+ SavedItem = Data.define(
13
+ :item_id,
14
+ :item_type,
15
+ :ts,
16
+ :state,
17
+ :date_created,
18
+ :date_due,
19
+ :date_completed,
20
+ :is_archived
21
+ ) do
22
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
23
+ def self.from_api(data)
24
+ new(
25
+ item_id: data['item_id'] || data['channel_id'] || data['conversation_id'],
26
+ item_type: data['item_type'] || data['type'] || 'message',
27
+ ts: data['ts'] || data['message_ts'],
28
+ state: data['state'] || SAVED_STATE_SAVED,
29
+ date_created: parse_timestamp(data['date_created']),
30
+ date_due: parse_timestamp(data['date_due']),
31
+ date_completed: parse_timestamp(data['date_completed']),
32
+ is_archived: data['is_archived'] || false
33
+ )
34
+ end
35
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
36
+
37
+ def self.parse_timestamp(value)
38
+ return nil if value.nil? || value.to_i.zero?
39
+
40
+ value.to_i
41
+ end
42
+
43
+ private_class_method :parse_timestamp
44
+
45
+ # rubocop:disable Naming/MethodParameterName
46
+ def initialize(
47
+ item_id:,
48
+ item_type: 'message',
49
+ ts: nil,
50
+ state: SAVED_STATE_SAVED,
51
+ date_created: nil,
52
+ date_due: nil,
53
+ date_completed: nil,
54
+ is_archived: false
55
+ )
56
+ super(
57
+ item_id: item_id.to_s.freeze,
58
+ item_type: item_type.to_s.freeze,
59
+ ts: ts&.to_s&.freeze,
60
+ state: state.to_s.freeze,
61
+ date_created: date_created,
62
+ date_due: date_due,
63
+ date_completed: date_completed,
64
+ is_archived: is_archived
65
+ )
66
+ end
67
+ # rubocop:enable Naming/MethodParameterName
68
+
69
+ # Channel ID alias for compatibility with message fetching
70
+ alias_method :channel_id, :item_id
71
+
72
+ # State predicates
73
+ def completed?
74
+ state == SAVED_STATE_COMPLETED
75
+ end
76
+
77
+ def in_progress?
78
+ state == SAVED_STATE_IN_PROGRESS
79
+ end
80
+
81
+ def saved?
82
+ state == SAVED_STATE_SAVED
83
+ end
84
+
85
+ def archived?
86
+ is_archived
87
+ end
88
+
89
+ # Due date helpers
90
+ def due_date?
91
+ !date_due.nil?
92
+ end
93
+
94
+ def overdue?
95
+ return false unless due_date?
96
+ return false if completed?
97
+
98
+ date_due < Time.now.to_i
99
+ end
100
+
101
+ def due_time
102
+ return nil unless due_date?
103
+
104
+ Time.at(date_due)
105
+ end
106
+
107
+ def created_time
108
+ return nil unless date_created
109
+
110
+ Time.at(date_created)
111
+ end
112
+
113
+ def completed_time
114
+ return nil unless date_completed
115
+
116
+ Time.at(date_completed)
117
+ end
118
+
119
+ # Time until/since due date
120
+ def time_until_due
121
+ return nil unless due_date?
122
+
123
+ date_due - Time.now.to_i
124
+ end
125
+ end
126
+ # rubocop:enable Metrics/ParameterLists
127
+ end
128
+ end
data/lib/slk/runner.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Slk
4
4
  # Dependency injection container providing services to commands
5
+ # rubocop:disable Metrics/ClassLength
5
6
  class Runner
6
7
  attr_reader :output, :config, :token_store, :api_client, :cache_store, :preset_store
7
8
 
@@ -83,6 +84,17 @@ module Slk
83
84
  Api::Search.new(@api_client, workspace(workspace_name))
84
85
  end
85
86
 
87
+ def saved_api(workspace_name = nil)
88
+ Api::Saved.new(@api_client, workspace(workspace_name))
89
+ end
90
+
91
+ def message_resolver(workspace_name = nil)
92
+ Services::MessageResolver.new(
93
+ conversations_api: conversations_api(workspace_name),
94
+ on_debug: ->(msg) { @output.debug(msg) }
95
+ )
96
+ end
97
+
86
98
  # Formatter helpers
87
99
  def message_formatter
88
100
  @message_formatter ||= Formatters::MessageFormatter.new(
@@ -107,6 +119,13 @@ module Slk
107
119
  @emoji_replacer ||= Formatters::EmojiReplacer.new
108
120
  end
109
121
 
122
+ def text_processor
123
+ @text_processor ||= Formatters::TextProcessor.new(
124
+ mention_replacer: mention_replacer,
125
+ emoji_replacer: emoji_replacer
126
+ )
127
+ end
128
+
110
129
  def duration_formatter
111
130
  @duration_formatter ||= Formatters::DurationFormatter.new
112
131
  end
@@ -114,8 +133,8 @@ module Slk
114
133
  def search_formatter
115
134
  @search_formatter ||= Formatters::SearchFormatter.new(
116
135
  output: @output,
117
- emoji_replacer: emoji_replacer,
118
- mention_replacer: mention_replacer
136
+ mention_replacer: mention_replacer,
137
+ text_processor: text_processor
119
138
  )
120
139
  end
121
140
 
@@ -135,4 +154,5 @@ module Slk
135
154
  @cache_store.on_warning = warning_handler
136
155
  end
137
156
  end
157
+ # rubocop:enable Metrics/ClassLength
138
158
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Services
5
+ # Resolves and fetches individual messages by timestamp from channels
6
+ # Used by activity feed, saved items, and other features that need to
7
+ # fetch message content by ts.
8
+ class MessageResolver
9
+ def initialize(conversations_api:, on_debug: nil)
10
+ @api = conversations_api
11
+ @on_debug = on_debug
12
+ end
13
+
14
+ # Fetch a single message by its timestamp from a channel
15
+ # @param channel_id [String] The channel ID
16
+ # @param message_ts [String] The message timestamp
17
+ # @return [Hash, nil] The message data or nil if not found
18
+ def fetch_by_ts(channel_id, message_ts)
19
+ response = fetch_message_history(channel_id, message_ts)
20
+ return nil unless response['ok'] && response['messages']&.any?
21
+
22
+ response['messages'].find { |msg| msg['ts'] == message_ts }
23
+ rescue ApiError => e
24
+ @on_debug&.call("Could not fetch message #{message_ts} from #{channel_id}: #{e.message}")
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def fetch_message_history(channel_id, message_ts)
31
+ # Use a narrow time window around the message timestamp
32
+ oldest_ts = (message_ts.to_f - 1).to_s
33
+ latest_ts = (message_ts.to_f + 1).to_s
34
+ @api.history(channel: channel_id, limit: 10, oldest: oldest_ts, latest: latest_ts)
35
+ end
36
+ end
37
+ end
38
+ end