slk 0.4.0 → 0.4.1
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 +23 -0
- data/lib/slk/api/saved.rb +26 -0
- data/lib/slk/cli.rb +12 -1
- data/lib/slk/commands/activity.rb +7 -13
- data/lib/slk/commands/base.rb +2 -1
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/later.rb +297 -0
- data/lib/slk/commands/messages.rb +4 -3
- data/lib/slk/formatters/activity_formatter.rb +4 -7
- data/lib/slk/formatters/markdown_output.rb +98 -0
- data/lib/slk/formatters/message_formatter.rb +10 -18
- data/lib/slk/formatters/reaction_formatter.rb +1 -1
- data/lib/slk/formatters/saved_item_formatter.rb +144 -0
- data/lib/slk/formatters/search_formatter.rb +4 -8
- data/lib/slk/formatters/text_processor.rb +48 -0
- data/lib/slk/models/saved_item.rb +128 -0
- data/lib/slk/runner.rb +22 -2
- data/lib/slk/services/message_resolver.rb +38 -0
- data/lib/slk/support/inline_images.rb +94 -10
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +7 -0
- metadata +8 -7
|
@@ -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,
|
|
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 =
|
|
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 =
|
|
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) {
|
|
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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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)
|
|
@@ -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:,
|
|
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
|
-
|
|
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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Slk
|
|
4
4
|
module Support
|
|
5
|
-
# Shared module for inline image display in
|
|
5
|
+
# Shared module for inline image display in terminals supporting
|
|
6
|
+
# iTerm2 protocol (iTerm2/WezTerm/Mintty) or Kitty graphics protocol (Ghostty/Kitty)
|
|
6
7
|
# Includes special handling for tmux passthrough sequences
|
|
7
8
|
module InlineImages
|
|
8
|
-
# Check if terminal supports
|
|
9
|
+
# Check if terminal supports any inline image protocol
|
|
9
10
|
def inline_images_supported?
|
|
11
|
+
iterm2_protocol_supported? || kitty_graphics_supported?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Check if terminal supports iTerm2 inline image protocol
|
|
15
|
+
def iterm2_protocol_supported?
|
|
10
16
|
# iTerm2, WezTerm, Mintty support inline images
|
|
11
17
|
# LC_TERMINAL persists through tmux/ssh
|
|
12
18
|
['iTerm.app', 'WezTerm'].include?(ENV.fetch('TERM_PROGRAM', nil)) ||
|
|
@@ -15,31 +21,88 @@ module Slk
|
|
|
15
21
|
ENV['TERM'] == 'mintty'
|
|
16
22
|
end
|
|
17
23
|
|
|
24
|
+
# Check if terminal supports Kitty graphics protocol (Ghostty, Kitty)
|
|
25
|
+
def kitty_graphics_supported?
|
|
26
|
+
ENV['TERM_PROGRAM'] == 'ghostty' ||
|
|
27
|
+
ENV['GHOSTTY_RESOURCES_DIR'] ||
|
|
28
|
+
ENV['TERM']&.include?('kitty') ||
|
|
29
|
+
tmux_client_is_kitty_compatible?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Check if tmux client terminal supports Kitty graphics
|
|
33
|
+
def tmux_client_is_kitty_compatible?
|
|
34
|
+
return false unless in_tmux?
|
|
35
|
+
|
|
36
|
+
@tmux_client_is_kitty_compatible ||= begin
|
|
37
|
+
output = begin
|
|
38
|
+
`tmux display-message -p '\#{client_termname}'`.chomp
|
|
39
|
+
rescue StandardError
|
|
40
|
+
''
|
|
41
|
+
end
|
|
42
|
+
output.include?('ghostty') || output.include?('kitty')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
18
46
|
# Check if running inside tmux
|
|
19
47
|
def in_tmux?
|
|
20
48
|
# tmux sets TERM to screen-* or tmux-*
|
|
21
49
|
ENV['TERM']&.include?('screen') || ENV['TERM']&.start_with?('tmux')
|
|
22
50
|
end
|
|
23
51
|
|
|
24
|
-
# Print an inline image using
|
|
52
|
+
# Print an inline image using the appropriate protocol
|
|
25
53
|
# In tmux, uses passthrough sequence and cursor positioning
|
|
26
54
|
def print_inline_image(path, height: 1)
|
|
27
|
-
data =
|
|
55
|
+
data = read_image_data_for_protocol(path)
|
|
28
56
|
return unless data
|
|
29
57
|
|
|
30
58
|
encoded = [data].pack('m0')
|
|
31
|
-
|
|
59
|
+
|
|
60
|
+
if kitty_graphics_supported?
|
|
61
|
+
in_tmux? ? print_tmux_kitty_image(encoded, height) : print_kitty_image(encoded, height)
|
|
62
|
+
else
|
|
63
|
+
in_tmux? ? print_tmux_iterm_image(encoded, height) : print_iterm_image(encoded, height)
|
|
64
|
+
end
|
|
32
65
|
end
|
|
33
66
|
|
|
34
|
-
def
|
|
67
|
+
def read_image_data_for_protocol(path)
|
|
35
68
|
return nil unless File.exist?(path)
|
|
36
69
|
|
|
37
|
-
File.binread(path)
|
|
70
|
+
data = File.binread(path)
|
|
71
|
+
return nil unless data
|
|
72
|
+
|
|
73
|
+
# Kitty protocol requires PNG format; convert GIF/JPEG if needed
|
|
74
|
+
if kitty_graphics_supported? && !png_data?(data)
|
|
75
|
+
convert_to_png(path)
|
|
76
|
+
else
|
|
77
|
+
data
|
|
78
|
+
end
|
|
38
79
|
rescue IOError, SystemCallError
|
|
39
80
|
nil
|
|
40
81
|
end
|
|
41
82
|
|
|
42
|
-
def
|
|
83
|
+
def png_data?(data)
|
|
84
|
+
# PNG files start with magic bytes: 137 80 78 71 13 10 26 10
|
|
85
|
+
data[0, 8]&.bytes == [137, 80, 78, 71, 13, 10, 26, 10]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def convert_to_png(path)
|
|
89
|
+
# Use sips (macOS) to convert to PNG
|
|
90
|
+
require 'tempfile'
|
|
91
|
+
temp = Tempfile.new(['emoji', '.png'])
|
|
92
|
+
temp.close
|
|
93
|
+
|
|
94
|
+
system('sips', '-s', 'format', 'png', path, '--out', temp.path,
|
|
95
|
+
out: File::NULL, err: File::NULL)
|
|
96
|
+
|
|
97
|
+
return nil unless File.exist?(temp.path) && File.size(temp.path).positive?
|
|
98
|
+
|
|
99
|
+
File.binread(temp.path)
|
|
100
|
+
ensure
|
|
101
|
+
temp&.unlink
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# iTerm2 protocol methods
|
|
105
|
+
def print_tmux_iterm_image(encoded, height)
|
|
43
106
|
fmt = "\ePtmux;\e\e]1337;File=inline=1;preserveAspectRatio=0;" \
|
|
44
107
|
"size=%<size>d;height=%<height>d:%<data>s\a\e\\\n "
|
|
45
108
|
printf fmt, size: encoded.length, height: height, data: encoded
|
|
@@ -49,6 +112,26 @@ module Slk
|
|
|
49
112
|
printf "\e]1337;File=inline=1;height=%<height>d:%<data>s\a", height: height, data: encoded
|
|
50
113
|
end
|
|
51
114
|
|
|
115
|
+
# Kitty graphics protocol methods (Ghostty, Kitty)
|
|
116
|
+
# a=T: transmit and display, q=1: suppress OK response, f=100: PNG, r=rows, m=0: no more chunks
|
|
117
|
+
def print_kitty_image(encoded, height)
|
|
118
|
+
printf "\e_Ga=T,q=1,f=100,r=%<height>d,m=0;%<data>s\e\\", height: height, data: encoded
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Kitty graphics with Unicode placeholders for tmux (images clear/scroll with text)
|
|
122
|
+
# See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
|
123
|
+
def print_tmux_kitty_image(encoded, _height)
|
|
124
|
+
@kitty_image_id ||= 30
|
|
125
|
+
@kitty_image_id = (@kitty_image_id % 255) + 1
|
|
126
|
+
image_id = @kitty_image_id
|
|
127
|
+
|
|
128
|
+
# tmux passthrough: transmit image with Unicode placeholder mode (U=1), 2 cols x 1 row
|
|
129
|
+
$stdout.print "\ePtmux;\e\e_Ga=T,U=1,q=1,f=100,i=#{image_id},c=2,r=1,m=0;#{encoded}\e\e\\\e\\"
|
|
130
|
+
# Output placeholder cells (U+10EEEE + row/col diacritics) with foreground color = image_id
|
|
131
|
+
$stdout.print "\e[38;5;#{image_id}m\u{10EEEE}\u0305\u0305\u{10EEEE}\u0305\u030D\e[39m"
|
|
132
|
+
$stdout.flush
|
|
133
|
+
end
|
|
134
|
+
|
|
52
135
|
# Print inline image with name on same line
|
|
53
136
|
# Handles tmux cursor positioning to keep image and text on same line
|
|
54
137
|
def print_inline_image_with_text(path, text, height: 1) # rubocop:disable Naming/PredicateMethod
|
|
@@ -56,11 +139,12 @@ module Slk
|
|
|
56
139
|
|
|
57
140
|
print_inline_image(path, height: height)
|
|
58
141
|
|
|
59
|
-
if in_tmux?
|
|
60
|
-
# tmux: image ends with \n + space, cursor on next line
|
|
142
|
+
if in_tmux? && !kitty_graphics_supported?
|
|
143
|
+
# iTerm2 in tmux: image ends with \n + space, cursor on next line
|
|
61
144
|
# Move up 1 line, right 3 cols (past image), then print text
|
|
62
145
|
print "\e[1A\e[3C#{text}\n"
|
|
63
146
|
else
|
|
147
|
+
# Direct output or Unicode placeholders (regular text, just print after)
|
|
64
148
|
puts " #{text}"
|
|
65
149
|
end
|
|
66
150
|
|
data/lib/slk/version.rb
CHANGED