slk 0.1.0 → 0.2.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 +22 -1
- data/README.md +5 -5
- data/bin/slk +3 -3
- data/lib/{slack_cli → slk}/api/activity.rb +10 -11
- data/lib/{slack_cli → slk}/api/bots.rb +5 -4
- data/lib/slk/api/client.rb +51 -0
- data/lib/{slack_cli → slk}/api/conversations.rb +14 -13
- data/lib/slk/api/dnd.rb +41 -0
- data/lib/{slack_cli → slk}/api/emoji.rb +4 -3
- data/lib/{slack_cli → slk}/api/threads.rb +13 -12
- data/lib/{slack_cli → slk}/api/usergroups.rb +2 -1
- data/lib/slk/api/users.rb +105 -0
- data/lib/slk/cli.rb +157 -0
- data/lib/slk/commands/activity.rb +152 -0
- data/lib/{slack_cli → slk}/commands/base.rb +67 -41
- data/lib/slk/commands/cache.rb +141 -0
- data/lib/slk/commands/catchup.rb +411 -0
- data/lib/slk/commands/config.rb +114 -0
- data/lib/slk/commands/dnd.rb +172 -0
- data/lib/slk/commands/emoji.rb +352 -0
- data/lib/slk/commands/help.rb +97 -0
- data/lib/slk/commands/messages.rb +299 -0
- data/lib/slk/commands/presence.rb +109 -0
- data/lib/slk/commands/preset.rb +231 -0
- data/lib/slk/commands/status.rb +223 -0
- data/lib/slk/commands/thread.rb +72 -0
- data/lib/slk/commands/unread.rb +305 -0
- data/lib/slk/commands/workspaces.rb +168 -0
- data/lib/slk/formatters/activity_formatter.rb +148 -0
- data/lib/slk/formatters/attachment_formatter.rb +65 -0
- data/lib/slk/formatters/block_formatter.rb +57 -0
- data/lib/{slack_cli → slk}/formatters/duration_formatter.rb +6 -5
- data/lib/slk/formatters/emoji_replacer.rb +141 -0
- data/lib/slk/formatters/json_message_formatter.rb +95 -0
- data/lib/slk/formatters/mention_replacer.rb +158 -0
- data/lib/slk/formatters/message_formatter.rb +174 -0
- data/lib/{slack_cli → slk}/formatters/output.rb +7 -6
- data/lib/slk/formatters/reaction_formatter.rb +87 -0
- data/lib/{slack_cli → slk}/models/channel.rb +12 -10
- data/lib/slk/models/duration.rb +94 -0
- data/lib/slk/models/message.rb +242 -0
- data/lib/slk/models/preset.rb +78 -0
- data/lib/{slack_cli → slk}/models/reaction.rb +6 -6
- data/lib/{slack_cli → slk}/models/status.rb +6 -6
- data/lib/slk/models/user.rb +55 -0
- data/lib/slk/models/workspace.rb +54 -0
- data/lib/{slack_cli → slk}/runner.rb +22 -19
- data/lib/slk/services/activity_enricher.rb +124 -0
- data/lib/slk/services/api_client.rb +145 -0
- data/lib/{slack_cli → slk}/services/cache_store.rb +20 -17
- data/lib/{slack_cli → slk}/services/configuration.rb +9 -8
- data/lib/slk/services/emoji_downloader.rb +103 -0
- data/lib/slk/services/emoji_searcher.rb +72 -0
- data/lib/{slack_cli → slk}/services/encryption.rb +11 -14
- data/lib/slk/services/gemoji_sync.rb +97 -0
- data/lib/{slack_cli → slk}/services/preset_store.rb +34 -33
- data/lib/slk/services/reaction_enricher.rb +82 -0
- data/lib/slk/services/setup_wizard.rb +131 -0
- data/lib/slk/services/target_resolver.rb +108 -0
- data/lib/{slack_cli → slk}/services/token_store.rb +11 -10
- data/lib/slk/services/unread_marker.rb +101 -0
- data/lib/{slack_cli → slk}/support/error_logger.rb +2 -1
- data/lib/{slack_cli → slk}/support/help_formatter.rb +36 -44
- data/lib/{slack_cli → slk}/support/inline_images.rb +28 -19
- data/lib/slk/support/interactive_prompt.rb +29 -0
- data/lib/{slack_cli → slk}/support/slack_url_parser.rb +15 -17
- data/lib/slk/support/text_wrapper.rb +57 -0
- data/lib/slk/support/user_resolver.rb +141 -0
- data/lib/{slack_cli → slk}/support/xdg_paths.rb +6 -5
- data/lib/slk/version.rb +5 -0
- data/lib/slk.rb +112 -0
- metadata +80 -59
- data/lib/slack_cli/api/client.rb +0 -49
- data/lib/slack_cli/api/dnd.rb +0 -40
- data/lib/slack_cli/api/users.rb +0 -101
- data/lib/slack_cli/cli.rb +0 -118
- data/lib/slack_cli/commands/activity.rb +0 -292
- data/lib/slack_cli/commands/cache.rb +0 -116
- data/lib/slack_cli/commands/catchup.rb +0 -484
- data/lib/slack_cli/commands/config.rb +0 -159
- data/lib/slack_cli/commands/dnd.rb +0 -143
- data/lib/slack_cli/commands/emoji.rb +0 -412
- data/lib/slack_cli/commands/help.rb +0 -76
- data/lib/slack_cli/commands/messages.rb +0 -317
- data/lib/slack_cli/commands/presence.rb +0 -107
- data/lib/slack_cli/commands/preset.rb +0 -239
- data/lib/slack_cli/commands/status.rb +0 -194
- data/lib/slack_cli/commands/thread.rb +0 -62
- data/lib/slack_cli/commands/unread.rb +0 -312
- data/lib/slack_cli/commands/workspaces.rb +0 -151
- data/lib/slack_cli/formatters/emoji_replacer.rb +0 -143
- data/lib/slack_cli/formatters/mention_replacer.rb +0 -154
- data/lib/slack_cli/formatters/message_formatter.rb +0 -429
- data/lib/slack_cli/models/duration.rb +0 -85
- data/lib/slack_cli/models/message.rb +0 -217
- data/lib/slack_cli/models/preset.rb +0 -73
- data/lib/slack_cli/models/user.rb +0 -56
- data/lib/slack_cli/models/workspace.rb +0 -52
- data/lib/slack_cli/services/api_client.rb +0 -149
- data/lib/slack_cli/services/reaction_enricher.rb +0 -87
- data/lib/slack_cli/support/user_resolver.rb +0 -114
- data/lib/slack_cli/version.rb +0 -5
- data/lib/slack_cli.rb +0 -91
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../support/help_formatter'
|
|
4
|
+
|
|
5
|
+
module Slk
|
|
6
|
+
module Commands
|
|
7
|
+
# Manages configured Slack workspaces
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
|
+
class Workspaces < Base
|
|
10
|
+
def execute
|
|
11
|
+
result = validate_options
|
|
12
|
+
return result if result
|
|
13
|
+
|
|
14
|
+
dispatch_action
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dispatch_action
|
|
18
|
+
case positional_args
|
|
19
|
+
in ['list'] | [] then list_workspaces
|
|
20
|
+
in ['add'] then add_workspace
|
|
21
|
+
in ['remove', name] then remove_workspace(name)
|
|
22
|
+
in ['primary'] then show_primary
|
|
23
|
+
in ['primary', name] then set_primary(name)
|
|
24
|
+
else unknown_action
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unknown_action
|
|
29
|
+
error("Unknown action: #{positional_args.first}")
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def help_text
|
|
36
|
+
help = Support::HelpFormatter.new('slk workspaces <action> [name]')
|
|
37
|
+
help.description('Manage Slack workspaces.')
|
|
38
|
+
add_actions_section(help)
|
|
39
|
+
add_options_section(help)
|
|
40
|
+
help.render
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_actions_section(help)
|
|
44
|
+
help.section('ACTIONS') do |s|
|
|
45
|
+
s.action('list', 'List configured workspaces')
|
|
46
|
+
s.action('add', 'Add a new workspace (interactive)')
|
|
47
|
+
s.action('remove <name>', 'Remove a workspace')
|
|
48
|
+
s.action('primary', 'Show primary workspace')
|
|
49
|
+
s.action('primary <name>', 'Set primary workspace')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def add_options_section(help)
|
|
54
|
+
help.section('OPTIONS') do |s|
|
|
55
|
+
s.option('-q, --quiet', 'Suppress output')
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def list_workspaces
|
|
62
|
+
names = runner.workspace_names
|
|
63
|
+
return show_no_workspaces if names.empty?
|
|
64
|
+
|
|
65
|
+
print_workspace_list(names, config.primary_workspace)
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def show_no_workspaces
|
|
70
|
+
puts 'No workspaces configured.'
|
|
71
|
+
puts "Run 'slack workspaces add' to add one."
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_workspace_list(names, primary)
|
|
76
|
+
puts 'Workspaces:'
|
|
77
|
+
names.each do |name|
|
|
78
|
+
marker = name == primary ? output.green('*') : ' '
|
|
79
|
+
puts " #{marker} #{name}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_workspace
|
|
84
|
+
name = prompt_for_name
|
|
85
|
+
return name if name.is_a?(Integer) # Error code
|
|
86
|
+
|
|
87
|
+
token, cookie = prompt_for_credentials
|
|
88
|
+
return token if token.is_a?(Integer) # Error code
|
|
89
|
+
|
|
90
|
+
save_workspace(name, token, cookie)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def prompt_for_name
|
|
94
|
+
print 'Workspace name: '
|
|
95
|
+
name = $stdin.gets&.chomp
|
|
96
|
+
return error('Name is required') if name.nil? || name.empty?
|
|
97
|
+
return error("Workspace '#{name}' already exists") if token_store.exists?(name)
|
|
98
|
+
|
|
99
|
+
name
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def prompt_for_credentials
|
|
103
|
+
print 'Token (xoxb-... or xoxc-...): '
|
|
104
|
+
token = $stdin.gets&.chomp
|
|
105
|
+
return error('Token is required') if token.nil? || token.empty?
|
|
106
|
+
|
|
107
|
+
cookie = prompt_for_cookie(token)
|
|
108
|
+
[token, cookie]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def prompt_for_cookie(token)
|
|
112
|
+
return nil unless token.start_with?('xoxc-')
|
|
113
|
+
|
|
114
|
+
print 'Cookie (d=...): '
|
|
115
|
+
$stdin.gets&.chomp
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def save_workspace(name, token, cookie)
|
|
119
|
+
token_store.add(name, token, cookie)
|
|
120
|
+
|
|
121
|
+
if runner.workspace_names.size == 1
|
|
122
|
+
config.primary_workspace = name
|
|
123
|
+
success("Added workspace '#{name}' (set as primary)")
|
|
124
|
+
else
|
|
125
|
+
success("Added workspace '#{name}'")
|
|
126
|
+
end
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def remove_workspace(name)
|
|
131
|
+
return error("Workspace '#{name}' not found") unless token_store.exists?(name)
|
|
132
|
+
|
|
133
|
+
token_store.remove(name)
|
|
134
|
+
handle_primary_after_removal(name)
|
|
135
|
+
0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_primary_after_removal(name)
|
|
139
|
+
return success("Removed workspace '#{name}'") unless config.primary_workspace == name
|
|
140
|
+
|
|
141
|
+
remaining = runner.workspace_names
|
|
142
|
+
if remaining.any?
|
|
143
|
+
config.primary_workspace = remaining.first
|
|
144
|
+
success("Removed workspace '#{name}'. Primary changed to '#{remaining.first}'")
|
|
145
|
+
else
|
|
146
|
+
config.primary_workspace = nil
|
|
147
|
+
success("Removed workspace '#{name}'")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def show_primary
|
|
152
|
+
primary = config.primary_workspace
|
|
153
|
+
puts primary ? "Primary workspace: #{primary}" : 'No primary workspace set.'
|
|
154
|
+
0
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def set_primary(name) # rubocop:disable Naming/AccessorMethodName
|
|
158
|
+
return error("Workspace '#{name}' not found") unless token_store.exists?(name)
|
|
159
|
+
|
|
160
|
+
config.primary_workspace = name
|
|
161
|
+
success("Primary workspace set to '#{name}'")
|
|
162
|
+
|
|
163
|
+
0
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
# rubocop:enable Metrics/ClassLength
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats activity feed items for terminal display
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
|
+
class ActivityFormatter
|
|
8
|
+
def initialize(output:, enricher:, emoji_replacer:, mention_replacer:, on_debug: nil)
|
|
9
|
+
@output = output
|
|
10
|
+
@enricher = enricher
|
|
11
|
+
@emoji = emoji_replacer
|
|
12
|
+
@mentions = mention_replacer
|
|
13
|
+
@on_debug = on_debug
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Display a list of activity items
|
|
17
|
+
def display_all(items, workspace, options: {})
|
|
18
|
+
return puts 'No activity found.' if items.empty?
|
|
19
|
+
|
|
20
|
+
items.each do |item|
|
|
21
|
+
display_item(item, workspace, options)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Display a single activity item
|
|
26
|
+
def display_item(item, workspace, options)
|
|
27
|
+
type = item.dig('item', 'type')
|
|
28
|
+
timestamp = format_time(item['feed_ts'])
|
|
29
|
+
dispatch_activity_display(type, item, workspace, timestamp, options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def dispatch_activity_display(type, item, workspace, timestamp, options)
|
|
35
|
+
case type
|
|
36
|
+
when 'message_reaction' then display_reaction(item, workspace, timestamp, options)
|
|
37
|
+
when 'at_user', 'at_user_group', 'at_channel', 'at_everyone'
|
|
38
|
+
display_mention(item, workspace, timestamp, options)
|
|
39
|
+
when 'thread_v2' then display_thread(item, workspace, timestamp, options)
|
|
40
|
+
when 'bot_dm_bundle' then display_bot_dm(item, workspace, timestamp, options)
|
|
41
|
+
else @on_debug&.call("Unknown activity type '#{type}' - skipping") if type
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_reaction(item, workspace, timestamp, options)
|
|
46
|
+
reaction_data = item.dig('item', 'reaction')
|
|
47
|
+
message_data = item.dig('item', 'message')
|
|
48
|
+
return debug_missing('reaction') unless reaction_data && message_data
|
|
49
|
+
|
|
50
|
+
username = @enricher.resolve_user(workspace, reaction_data['user'])
|
|
51
|
+
emoji = @emoji.lookup_emoji(reaction_data['name']) || ":#{reaction_data['name']}:"
|
|
52
|
+
channel = @enricher.resolve_channel(workspace, message_data['channel'])
|
|
53
|
+
|
|
54
|
+
puts "#{@output.blue(timestamp)} #{@output.bold(username)} reacted #{emoji} in #{channel}"
|
|
55
|
+
show_message_preview(workspace, message_data, options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def display_mention(item, workspace, timestamp, options)
|
|
59
|
+
message_data = item.dig('item', 'message')
|
|
60
|
+
return debug_missing('mention') unless message_data
|
|
61
|
+
|
|
62
|
+
user_id = message_data['author_user_id'] || message_data['user']
|
|
63
|
+
username = @enricher.resolve_user(workspace, user_id)
|
|
64
|
+
channel = @enricher.resolve_channel(workspace, message_data['channel'])
|
|
65
|
+
|
|
66
|
+
puts "#{@output.blue(timestamp)} #{@output.bold(username)} mentioned you in #{channel}"
|
|
67
|
+
show_message_preview(workspace, message_data, options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def display_thread(item, workspace, timestamp, options)
|
|
71
|
+
thread_entry = item.dig('item', 'bundle_info', 'payload', 'thread_entry')
|
|
72
|
+
return debug_missing('thread') unless thread_entry
|
|
73
|
+
|
|
74
|
+
channel = @enricher.resolve_channel(workspace, thread_entry['channel_id'])
|
|
75
|
+
puts "#{@output.blue(timestamp)} Thread activity in #{channel}"
|
|
76
|
+
|
|
77
|
+
return unless options[:show_messages] && thread_entry['thread_ts']
|
|
78
|
+
|
|
79
|
+
message_data = { 'channel' => thread_entry['channel_id'], 'ts' => thread_entry['thread_ts'] }
|
|
80
|
+
show_message_preview(workspace, message_data, options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def display_bot_dm(item, workspace, timestamp, options)
|
|
84
|
+
message_data = item.dig('item', 'bundle_info', 'payload', 'message')
|
|
85
|
+
return debug_missing('bot DM') unless message_data
|
|
86
|
+
|
|
87
|
+
channel = @enricher.resolve_channel(workspace, message_data['channel'])
|
|
88
|
+
puts "#{@output.blue(timestamp)} Bot message in #{channel}"
|
|
89
|
+
show_message_preview(workspace, message_data, options)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def show_message_preview(workspace, message_data, options)
|
|
93
|
+
return unless options[:show_messages]
|
|
94
|
+
return unless options[:fetch_message]
|
|
95
|
+
|
|
96
|
+
message = options[:fetch_message].call(workspace, message_data['channel'], message_data['ts'])
|
|
97
|
+
display_message_content(message, workspace) if message
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def display_message_content(message, workspace)
|
|
101
|
+
username = resolve_message_author(message, workspace)
|
|
102
|
+
text = prepare_message_text(message, workspace)
|
|
103
|
+
|
|
104
|
+
lines = text.lines
|
|
105
|
+
first_line = truncate(lines.first&.strip || text, 100)
|
|
106
|
+
puts " └─ #{username}: #{first_line}"
|
|
107
|
+
|
|
108
|
+
display_additional_lines(lines) if lines.length > 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_message_author(message, workspace)
|
|
112
|
+
if message['user']
|
|
113
|
+
@enricher.resolve_user(workspace, message['user'])
|
|
114
|
+
elsif message['bot_id']
|
|
115
|
+
'Bot'
|
|
116
|
+
else
|
|
117
|
+
'Unknown'
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def prepare_message_text(message, workspace)
|
|
122
|
+
text = message['text'] || ''
|
|
123
|
+
return '[No text]' if text.empty?
|
|
124
|
+
|
|
125
|
+
@mentions.replace(text, workspace)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def display_additional_lines(lines)
|
|
129
|
+
remaining = lines[1..2].map(&:strip).reject(&:empty?)
|
|
130
|
+
remaining.each { |line| puts " #{truncate(line, 100)}" }
|
|
131
|
+
puts " [#{lines.length - 3} more lines...]" if lines.length > 3
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def truncate(text, max_length)
|
|
135
|
+
text.length > max_length ? "#{text[0..max_length]}..." : text
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def format_time(slack_timestamp)
|
|
139
|
+
Time.at(slack_timestamp.to_f).strftime('%b %d %-I:%M %p')
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def debug_missing(item_type)
|
|
143
|
+
@on_debug&.call("Could not display #{item_type} activity - missing data")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
# rubocop:enable Metrics/ClassLength
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats Slack message attachments for terminal display
|
|
6
|
+
class AttachmentFormatter
|
|
7
|
+
def initialize(output:, text_processor:)
|
|
8
|
+
@output = output
|
|
9
|
+
@text_processor = text_processor
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def format(attachments, lines, options)
|
|
13
|
+
return if attachments.empty?
|
|
14
|
+
return if options[:no_attachments]
|
|
15
|
+
|
|
16
|
+
attachments.each { |att| format_attachment(att, lines, options) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def format_attachment(attachment, lines, options)
|
|
22
|
+
att_text = attachment['text'] || attachment['fallback']
|
|
23
|
+
image_url = attachment['image_url'] || attachment['thumb_url']
|
|
24
|
+
|
|
25
|
+
return unless att_text || image_url
|
|
26
|
+
|
|
27
|
+
lines << ''
|
|
28
|
+
format_author(attachment, lines)
|
|
29
|
+
format_text(att_text, lines, options) if att_text
|
|
30
|
+
format_image(attachment, image_url, lines) if image_url
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def format_author(attachment, lines)
|
|
34
|
+
author = attachment['author_name'] || attachment['author_subname']
|
|
35
|
+
lines << "> #{@output.bold(author)}:" if author
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def format_text(att_text, lines, options)
|
|
39
|
+
processed_text = @text_processor.call(att_text)
|
|
40
|
+
processed_text = wrap_text(processed_text, options[:width])
|
|
41
|
+
|
|
42
|
+
processed_text.each_line do |line|
|
|
43
|
+
lines << "> #{line.chomp}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def wrap_text(text, width)
|
|
48
|
+
return text unless width && width > 2
|
|
49
|
+
|
|
50
|
+
Support::TextWrapper.wrap(text, width - 2, width - 2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_image(attachment, image_url, lines)
|
|
54
|
+
filename = attachment['title'] || extract_filename(image_url)
|
|
55
|
+
lines << "> [Image: #{filename}]"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_filename(url)
|
|
59
|
+
File.basename(URI.parse(url).path)
|
|
60
|
+
rescue URI::InvalidURIError
|
|
61
|
+
'image'
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formats Slack Block Kit blocks for terminal display
|
|
6
|
+
class BlockFormatter
|
|
7
|
+
def initialize(text_processor:)
|
|
8
|
+
@text_processor = text_processor
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def format(blocks, main_text, lines, options)
|
|
12
|
+
return unless blocks&.any?
|
|
13
|
+
return if options[:no_blocks]
|
|
14
|
+
|
|
15
|
+
block_texts = extract_texts(blocks)
|
|
16
|
+
block_texts = filter_duplicate_texts(block_texts, main_text)
|
|
17
|
+
|
|
18
|
+
return if block_texts.empty?
|
|
19
|
+
|
|
20
|
+
lines << ''
|
|
21
|
+
block_texts.each { |text| format_block_text(text, lines, options) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def extract_texts(blocks)
|
|
27
|
+
return [] unless blocks.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
blocks.filter_map do |block|
|
|
30
|
+
block.dig('text', 'text') if block['type'] == 'section'
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def filter_duplicate_texts(block_texts, main_text)
|
|
35
|
+
normalized_main = normalize(main_text)
|
|
36
|
+
block_texts.reject { |bt| normalize(bt) == normalized_main }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize(text)
|
|
40
|
+
text.to_s.gsub(/\s+/, ' ').strip.downcase
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def format_block_text(block_text, lines, options)
|
|
44
|
+
processed = @text_processor.call(block_text)
|
|
45
|
+
processed = wrap_text(processed, options[:width])
|
|
46
|
+
|
|
47
|
+
processed.each_line { |line| lines << "> #{line.chomp}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wrap_text(text, width)
|
|
51
|
+
return text unless width && width > 2
|
|
52
|
+
|
|
53
|
+
Support::TextWrapper.wrap(text, width - 2, width - 2)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Slk
|
|
4
4
|
module Formatters
|
|
5
|
+
# Formats Duration objects for display
|
|
5
6
|
class DurationFormatter
|
|
6
7
|
def format(duration)
|
|
7
|
-
return
|
|
8
|
+
return '' if duration.nil? || duration.zero?
|
|
8
9
|
|
|
9
10
|
duration.to_s
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def format_remaining(seconds)
|
|
13
|
-
return
|
|
14
|
+
return '' if seconds.nil? || seconds <= 0
|
|
14
15
|
|
|
15
16
|
Models::Duration.new(seconds: seconds).to_s
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def format_until(timestamp)
|
|
19
|
-
return
|
|
20
|
+
return '' if timestamp.nil? || timestamp <= 0
|
|
20
21
|
|
|
21
22
|
remaining = timestamp - Time.now.to_i
|
|
22
|
-
return
|
|
23
|
+
return 'expired' if remaining <= 0
|
|
23
24
|
|
|
24
25
|
format_remaining(remaining)
|
|
25
26
|
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Formatters
|
|
5
|
+
# Replaces :emoji: codes with unicode characters
|
|
6
|
+
class EmojiReplacer
|
|
7
|
+
EMOJI_REGEX = /:([a-zA-Z0-9_+-]+):/
|
|
8
|
+
SKIN_TONE_REGEX = /::skin-tone-(\d)/
|
|
9
|
+
|
|
10
|
+
# Common emoji mappings (subset - full list would be much larger)
|
|
11
|
+
EMOJI_MAP = {
|
|
12
|
+
# Faces
|
|
13
|
+
'smile' => "\u{1F604}", 'grinning' => "\u{1F600}", 'joy' => "\u{1F602}",
|
|
14
|
+
'rofl' => "\u{1F923}", 'smiley' => "\u{1F603}", 'sweat_smile' => "\u{1F605}",
|
|
15
|
+
'laughing' => "\u{1F606}", 'wink' => "\u{1F609}", 'blush' => "\u{1F60A}",
|
|
16
|
+
'yum' => "\u{1F60B}", 'sunglasses' => "\u{1F60E}", 'heart_eyes' => "\u{1F60D}",
|
|
17
|
+
'kissing_heart' => "\u{1F618}", 'thinking' => "\u{1F914}", 'thinking_face' => "\u{1F914}",
|
|
18
|
+
'raised_eyebrow' => "\u{1F928}", 'neutral_face' => "\u{1F610}", 'expressionless' => "\u{1F611}",
|
|
19
|
+
'unamused' => "\u{1F612}", 'rolling_eyes' => "\u{1F644}", 'grimacing' => "\u{1F62C}",
|
|
20
|
+
'relieved' => "\u{1F60C}", 'pensive' => "\u{1F614}", 'sleepy' => "\u{1F62A}",
|
|
21
|
+
'sleeping' => "\u{1F634}", 'sob' => "\u{1F62D}", 'cry' => "\u{1F622}",
|
|
22
|
+
'scream' => "\u{1F631}", 'angry' => "\u{1F620}", 'rage' => "\u{1F621}",
|
|
23
|
+
|
|
24
|
+
# Gestures
|
|
25
|
+
'wave' => "\u{1F44B}", '+1' => "\u{1F44D}", '-1' => "\u{1F44E}",
|
|
26
|
+
'thumbsup' => "\u{1F44D}", 'thumbsdown' => "\u{1F44E}",
|
|
27
|
+
'clap' => "\u{1F44F}", 'raised_hands' => "\u{1F64C}", 'pray' => "\u{1F64F}",
|
|
28
|
+
'point_up' => "\u{261D}", 'point_down' => "\u{1F447}", 'point_left' => "\u{1F448}",
|
|
29
|
+
'point_right' => "\u{1F449}", 'ok_hand' => "\u{1F44C}", 'v' => "\u{270C}",
|
|
30
|
+
'muscle' => "\u{1F4AA}", 'fist' => "\u{270A}",
|
|
31
|
+
|
|
32
|
+
# Hearts
|
|
33
|
+
'heart' => "\u{2764}", 'hearts' => "\u{2665}", 'yellow_heart' => "\u{1F49B}",
|
|
34
|
+
'green_heart' => "\u{1F49A}", 'blue_heart' => "\u{1F499}", 'purple_heart' => "\u{1F49C}",
|
|
35
|
+
'black_heart' => "\u{1F5A4}", 'broken_heart' => "\u{1F494}", 'sparkling_heart' => "\u{1F496}",
|
|
36
|
+
|
|
37
|
+
# Objects
|
|
38
|
+
'fire' => "\u{1F525}", 'star' => "\u{2B50}", 'sparkles' => "\u{2728}",
|
|
39
|
+
'boom' => "\u{1F4A5}", 'zap' => "\u{26A1}", 'sunny' => "\u{2600}",
|
|
40
|
+
'cloud' => "\u{2601}", 'umbrella' => "\u{2614}", 'snowflake' => "\u{2744}",
|
|
41
|
+
'rocket' => "\u{1F680}", 'airplane' => "\u{2708}", 'car' => "\u{1F697}",
|
|
42
|
+
'gift' => "\u{1F381}", 'trophy' => "\u{1F3C6}", 'medal' => "\u{1F3C5}",
|
|
43
|
+
'bell' => "\u{1F514}", 'key' => "\u{1F511}", 'lock' => "\u{1F512}",
|
|
44
|
+
'bulb' => "\u{1F4A1}", 'book' => "\u{1F4D6}", 'pencil' => "\u{270F}",
|
|
45
|
+
'memo' => "\u{1F4DD}", 'computer' => "\u{1F4BB}", 'phone' => "\u{1F4F1}",
|
|
46
|
+
'camera' => "\u{1F4F7}", 'headphones' => "\u{1F3A7}", 'microphone' => "\u{1F3A4}",
|
|
47
|
+
|
|
48
|
+
# Food
|
|
49
|
+
'coffee' => "\u{2615}", 'tea' => "\u{1F375}", 'beer' => "\u{1F37A}",
|
|
50
|
+
'wine_glass' => "\u{1F377}", 'pizza' => "\u{1F355}", 'hamburger' => "\u{1F354}",
|
|
51
|
+
'cake' => "\u{1F370}", 'cookie' => "\u{1F36A}", 'apple' => "\u{1F34E}",
|
|
52
|
+
'banana' => "\u{1F34C}", 'taco' => "\u{1F32E}", 'burrito' => "\u{1F32F}",
|
|
53
|
+
'knife_fork_plate' => "\u{1F37D}",
|
|
54
|
+
|
|
55
|
+
# Nature
|
|
56
|
+
'dog' => "\u{1F436}", 'cat' => "\u{1F431}", 'mouse' => "\u{1F42D}",
|
|
57
|
+
'rabbit' => "\u{1F430}", 'bear' => "\u{1F43B}", 'panda_face' => "\u{1F43C}",
|
|
58
|
+
'chicken' => "\u{1F414}", 'penguin' => "\u{1F427}", 'bird' => "\u{1F426}",
|
|
59
|
+
'fish' => "\u{1F41F}", 'bug' => "\u{1F41B}", 'bee' => "\u{1F41D}",
|
|
60
|
+
'rose' => "\u{1F339}", 'sunflower' => "\u{1F33B}", 'tree' => "\u{1F333}",
|
|
61
|
+
'cactus' => "\u{1F335}", 'palm_tree' => "\u{1F334}",
|
|
62
|
+
|
|
63
|
+
# Symbols
|
|
64
|
+
'white_check_mark' => "\u{2705}", 'heavy_check_mark' => "\u{2714}",
|
|
65
|
+
'x' => "\u{274C}", 'heavy_multiplication_x' => "\u{2716}",
|
|
66
|
+
'warning' => "\u{26A0}", 'no_entry' => "\u{26D4}", 'sos' => "\u{1F198}",
|
|
67
|
+
'question' => "\u{2753}", 'exclamation' => "\u{2757}", 'bangbang' => "\u{203C}",
|
|
68
|
+
'100' => "\u{1F4AF}", '1234' => "\u{1F522}",
|
|
69
|
+
|
|
70
|
+
# Status-related
|
|
71
|
+
'house' => "\u{1F3E0}", 'office' => "\u{1F3E2}", 'hospital' => "\u{1F3E5}",
|
|
72
|
+
'calendar' => "\u{1F4C5}", 'date' => "\u{1F4C5}", 'spiral_calendar' => "\u{1F5D3}",
|
|
73
|
+
'clock1' => "\u{1F550}", 'hourglass' => "\u{231B}", 'stopwatch' => "\u{23F1}",
|
|
74
|
+
'zzz' => "\u{1F4A4}", 'speech_balloon' => "\u{1F4AC}", 'thought_balloon' => "\u{1F4AD}",
|
|
75
|
+
|
|
76
|
+
# Common Slack custom
|
|
77
|
+
'party-blob' => "\u{1F389}", 'blob-wave' => "\u{1F44B}",
|
|
78
|
+
'tada' => "\u{1F389}", 'confetti_ball' => "\u{1F38A}",
|
|
79
|
+
'balloon' => "\u{1F388}", 'party_popper' => "\u{1F389}",
|
|
80
|
+
'eyes' => "\u{1F440}", 'eye' => "\u{1F441}",
|
|
81
|
+
'ear' => "\u{1F442}", 'nose' => "\u{1F443}",
|
|
82
|
+
'brb' => "\u{1F6B6}", 'away' => "\u{1F6B6}",
|
|
83
|
+
'test_tube' => "\u{1F9EA}"
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
def initialize(custom_emoji: {}, on_debug: nil)
|
|
87
|
+
@custom_emoji = custom_emoji
|
|
88
|
+
@on_debug = on_debug
|
|
89
|
+
@gemoji_cache = load_gemoji_cache
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def replace(text, _workspace = nil)
|
|
93
|
+
result = text.dup
|
|
94
|
+
|
|
95
|
+
# Remove skin tone modifiers (we don't render them in terminal)
|
|
96
|
+
result.gsub!(SKIN_TONE_REGEX, '')
|
|
97
|
+
|
|
98
|
+
# Replace emoji codes
|
|
99
|
+
result.gsub!(EMOJI_REGEX) do
|
|
100
|
+
name = ::Regexp.last_match(1)
|
|
101
|
+
lookup_emoji(name) || ":#{name}:"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def lookup_emoji(name)
|
|
108
|
+
# Check custom emoji first
|
|
109
|
+
return nil if @custom_emoji[name] # Custom emoji are URLs, skip for now
|
|
110
|
+
|
|
111
|
+
# Check gemoji cache first (from sync-standard)
|
|
112
|
+
return @gemoji_cache[name] if @gemoji_cache&.key?(name)
|
|
113
|
+
|
|
114
|
+
# Fall back to built-in map
|
|
115
|
+
EMOJI_MAP[name]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def with_custom_emoji(emoji_hash)
|
|
119
|
+
self.class.new(custom_emoji: emoji_hash, on_debug: @on_debug)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def load_gemoji_cache
|
|
125
|
+
cache_path = gemoji_cache_path
|
|
126
|
+
return nil unless File.exist?(cache_path)
|
|
127
|
+
|
|
128
|
+
JSON.parse(File.read(cache_path))
|
|
129
|
+
rescue JSON::ParserError => e
|
|
130
|
+
@on_debug&.call("Failed to load gemoji cache: #{e.message}")
|
|
131
|
+
nil
|
|
132
|
+
rescue Errno::ENOENT
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def gemoji_cache_path
|
|
137
|
+
File.join(ENV.fetch('XDG_CACHE_HOME', File.expand_path('~/.cache')), 'slk', 'gemoji.json')
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|