slk 0.2.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 +62 -1
- data/README.md +30 -12
- data/bin/ci +15 -0
- data/bin/coverage +225 -0
- data/bin/test +7 -0
- data/lib/slk/api/saved.rb +26 -0
- data/lib/slk/api/search.rb +31 -0
- data/lib/slk/cli.rb +61 -3
- data/lib/slk/commands/activity.rb +7 -13
- data/lib/slk/commands/base.rb +3 -1
- data/lib/slk/commands/catchup.rb +3 -2
- data/lib/slk/commands/config.rb +48 -45
- data/lib/slk/commands/help.rb +3 -0
- data/lib/slk/commands/later.rb +297 -0
- data/lib/slk/commands/messages.rb +63 -14
- data/lib/slk/commands/search.rb +223 -0
- data/lib/slk/commands/ssh_key_manager.rb +129 -0
- data/lib/slk/formatters/activity_formatter.rb +4 -7
- data/lib/slk/formatters/attachment_formatter.rb +16 -2
- data/lib/slk/formatters/markdown_output.rb +98 -0
- data/lib/slk/formatters/mention_replacer.rb +13 -31
- data/lib/slk/formatters/message_formatter.rb +18 -33
- 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 +71 -0
- data/lib/slk/formatters/text_processor.rb +48 -0
- data/lib/slk/models/saved_item.rb +128 -0
- data/lib/slk/models/search_result.rb +115 -0
- data/lib/slk/runner.rb +32 -0
- data/lib/slk/services/api_client.rb +60 -11
- data/lib/slk/services/cache_store.rb +55 -36
- data/lib/slk/services/encryption.rb +114 -11
- data/lib/slk/services/message_resolver.rb +38 -0
- data/lib/slk/services/setup_wizard.rb +3 -3
- data/lib/slk/services/target_resolver.rb +27 -4
- data/lib/slk/services/token_loader.rb +83 -0
- data/lib/slk/services/token_saver.rb +87 -0
- data/lib/slk/services/token_store.rb +35 -65
- data/lib/slk/services/user_lookup.rb +117 -0
- data/lib/slk/support/date_parser.rb +64 -0
- data/lib/slk/support/inline_images.rb +94 -10
- data/lib/slk/support/platform.rb +34 -0
- data/lib/slk/support/xdg_paths.rb +27 -9
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +15 -0
- metadata +21 -7
|
@@ -1,118 +1,88 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'token_loader'
|
|
4
|
+
require_relative 'token_saver'
|
|
5
|
+
|
|
3
6
|
module Slk
|
|
4
7
|
module Services
|
|
5
8
|
# Manages workspace tokens with optional encryption
|
|
6
9
|
class TokenStore
|
|
7
|
-
attr_accessor :on_warning
|
|
10
|
+
attr_accessor :on_warning, :on_info, :on_prompt_pub_key
|
|
8
11
|
|
|
9
12
|
def initialize(config: nil, encryption: nil, paths: nil)
|
|
10
13
|
@config = config || Configuration.new
|
|
11
14
|
@encryption = encryption || Encryption.new
|
|
12
15
|
@paths = paths || Support::XdgPaths.new
|
|
16
|
+
@loader = TokenLoader.new(encryption: @encryption, paths: @paths)
|
|
17
|
+
@saver = TokenSaver.new(encryption: @encryption, paths: @paths)
|
|
13
18
|
@on_warning = nil
|
|
19
|
+
@on_info = nil
|
|
20
|
+
@on_prompt_pub_key = nil
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
def workspace(name)
|
|
17
|
-
tokens =
|
|
24
|
+
tokens = @loader.load_auto(@config)
|
|
18
25
|
data = tokens[name]
|
|
19
26
|
raise WorkspaceNotFoundError, "Workspace '#{name}' not found" unless data
|
|
20
27
|
|
|
21
|
-
Models::Workspace.new(
|
|
22
|
-
name: name,
|
|
23
|
-
token: data['token'],
|
|
24
|
-
cookie: data['cookie']
|
|
25
|
-
)
|
|
28
|
+
Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def all_workspaces
|
|
29
|
-
|
|
30
|
-
Models::Workspace.new(
|
|
31
|
-
name: name,
|
|
32
|
-
token: data['token'],
|
|
33
|
-
cookie: data['cookie']
|
|
34
|
-
)
|
|
32
|
+
@loader.load_auto(@config).map do |name, data|
|
|
33
|
+
Models::Workspace.new(name: name, token: data['token'], cookie: data['cookie'])
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
def workspace_names
|
|
39
|
-
|
|
38
|
+
@loader.load_auto(@config).keys
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
def exists?(name)
|
|
43
|
-
|
|
42
|
+
@loader.load_auto(@config).key?(name)
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
def add(name, token, cookie = nil)
|
|
47
|
-
# Validate by constructing a Workspace (will raise ArgumentError if invalid)
|
|
48
46
|
Models::Workspace.new(name: name, token: token, cookie: cookie)
|
|
49
|
-
|
|
50
|
-
tokens = load_tokens
|
|
47
|
+
tokens = @loader.load_auto(@config)
|
|
51
48
|
tokens[name] = { 'token' => token, 'cookie' => cookie }.compact
|
|
52
|
-
|
|
49
|
+
@saver.save(tokens, @config.ssh_key)
|
|
53
50
|
end
|
|
54
51
|
|
|
55
52
|
def remove(name) # rubocop:disable Naming/PredicateMethod
|
|
56
|
-
tokens =
|
|
53
|
+
tokens = @loader.load_auto(@config)
|
|
57
54
|
removed = tokens.delete(name)
|
|
58
|
-
|
|
55
|
+
@saver.save(tokens, @config.ssh_key) if removed
|
|
59
56
|
!removed.nil?
|
|
60
57
|
end
|
|
61
58
|
|
|
62
59
|
def empty?
|
|
63
|
-
|
|
60
|
+
@loader.load_auto(@config).empty?
|
|
64
61
|
end
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
def migrate_encryption(old_ssh_key, new_ssh_key)
|
|
64
|
+
return if old_ssh_key == new_ssh_key
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
if
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
rescue JSON::ParserError => e
|
|
77
|
-
raise TokenStoreError, "Tokens file #{plain_tokens_file} is corrupted: #{e.message}"
|
|
66
|
+
tokens = @loader.load(old_ssh_key)
|
|
67
|
+
return if tokens.empty?
|
|
68
|
+
|
|
69
|
+
@encryption.on_prompt_pub_key = @on_prompt_pub_key
|
|
70
|
+
@encryption.validate_key_type!(new_ssh_key) if new_ssh_key
|
|
71
|
+
@saver.save_with_cleanup(tokens, new_ssh_key)
|
|
72
|
+
notify_encryption_change(old_ssh_key, new_ssh_key)
|
|
78
73
|
end
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
@paths.ensure_config_dir
|
|
75
|
+
private
|
|
82
76
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@
|
|
86
|
-
|
|
77
|
+
def notify_encryption_change(old_ssh_key, new_ssh_key)
|
|
78
|
+
if new_ssh_key && old_ssh_key
|
|
79
|
+
@on_info&.call('Tokens have been re-encrypted with the new SSH key.')
|
|
80
|
+
elsif new_ssh_key
|
|
81
|
+
@on_info&.call('Tokens have been encrypted with the new SSH key.')
|
|
87
82
|
else
|
|
88
|
-
|
|
89
|
-
File.write(plain_tokens_file, JSON.pretty_generate(tokens))
|
|
90
|
-
File.chmod(0o600, plain_tokens_file)
|
|
83
|
+
@on_warning&.call('Tokens are now stored in plaintext.')
|
|
91
84
|
end
|
|
92
85
|
end
|
|
93
|
-
|
|
94
|
-
def decrypt_tokens
|
|
95
|
-
content = @encryption.decrypt(encrypted_tokens_file, @config.ssh_key)
|
|
96
|
-
content ? JSON.parse(content) : {}
|
|
97
|
-
rescue JSON::ParserError => e
|
|
98
|
-
raise TokenStoreError, "Encrypted tokens file is corrupted: #{e.message}"
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def encrypted_file_exists?
|
|
102
|
-
File.exist?(encrypted_tokens_file)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def plain_file_exists?
|
|
106
|
-
File.exist?(plain_tokens_file)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def encrypted_tokens_file
|
|
110
|
-
@paths.config_file('tokens.age')
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def plain_tokens_file
|
|
114
|
-
@paths.config_file('tokens.json')
|
|
115
|
-
end
|
|
116
86
|
end
|
|
117
87
|
end
|
|
118
88
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Services
|
|
5
|
+
# Consolidated service for user name resolution
|
|
6
|
+
# Provides ID → name and name → ID lookups with caching
|
|
7
|
+
class UserLookup
|
|
8
|
+
def initialize(cache_store:, workspace:, api_client: nil, on_debug: nil)
|
|
9
|
+
@cache = cache_store
|
|
10
|
+
@api = api_client
|
|
11
|
+
@workspace = workspace
|
|
12
|
+
@on_debug = on_debug
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Resolve user ID to display name (most common use case)
|
|
16
|
+
# @param user_id [String] Slack user ID (e.g., "U123ABC")
|
|
17
|
+
# @return [String, nil] Display name or nil if not found
|
|
18
|
+
def resolve_name(user_id)
|
|
19
|
+
return nil if user_id.to_s.empty?
|
|
20
|
+
|
|
21
|
+
cached = @cache.get_user(@workspace.name, user_id)
|
|
22
|
+
return cached if cached
|
|
23
|
+
|
|
24
|
+
fetch_and_cache_name(user_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve user ID to display name, handling bots
|
|
28
|
+
# @param user_id [String] Slack user or bot ID
|
|
29
|
+
# @return [String, nil] Display name or nil if not found
|
|
30
|
+
def resolve_name_or_bot(user_id)
|
|
31
|
+
return nil if user_id.to_s.empty?
|
|
32
|
+
|
|
33
|
+
cached = @cache.get_user(@workspace.name, user_id)
|
|
34
|
+
return cached if cached
|
|
35
|
+
|
|
36
|
+
if user_id.start_with?('B')
|
|
37
|
+
fetch_and_cache_bot_name(user_id)
|
|
38
|
+
else
|
|
39
|
+
fetch_and_cache_name(user_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Find user ID by display name (reverse lookup)
|
|
44
|
+
# @param name [String] Display name to search for
|
|
45
|
+
# @return [String, nil] User ID or nil if not found
|
|
46
|
+
def find_id_by_name(name)
|
|
47
|
+
return nil if name.to_s.empty?
|
|
48
|
+
|
|
49
|
+
cached = @cache.get_user_id_by_name(@workspace.name, name)
|
|
50
|
+
return cached if cached
|
|
51
|
+
|
|
52
|
+
fetch_id_by_name(name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fetch_and_cache_name(user_id)
|
|
58
|
+
return nil unless @api
|
|
59
|
+
|
|
60
|
+
user = fetch_user(user_id)
|
|
61
|
+
return nil unless user
|
|
62
|
+
|
|
63
|
+
@cache.set_user(@workspace.name, user_id, user.best_name, persist: true)
|
|
64
|
+
user.best_name
|
|
65
|
+
rescue ApiError => e
|
|
66
|
+
@on_debug&.call("User lookup failed for #{user_id}: #{e.message}")
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fetch_and_cache_bot_name(bot_id)
|
|
71
|
+
return nil unless @api
|
|
72
|
+
|
|
73
|
+
bots_api = Api::Bots.new(@api, @workspace, on_debug: @on_debug)
|
|
74
|
+
name = bots_api.get_name(bot_id)
|
|
75
|
+
@cache.set_user(@workspace.name, bot_id, name, persist: true) if name
|
|
76
|
+
name
|
|
77
|
+
rescue ApiError => e
|
|
78
|
+
@on_debug&.call("Bot lookup failed for #{bot_id}: #{e.message}")
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetch_user(user_id)
|
|
83
|
+
users_api = Api::Users.new(@api, @workspace, on_debug: @on_debug)
|
|
84
|
+
response = users_api.info(user_id)
|
|
85
|
+
return nil unless response['ok'] && response['user']
|
|
86
|
+
|
|
87
|
+
Models::User.from_api(response['user'])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
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)
|
|
96
|
+
cache_user_from_api(user) if user
|
|
97
|
+
user&.dig('id')
|
|
98
|
+
rescue ApiError => e
|
|
99
|
+
@on_debug&.call("User list lookup failed: #{e.message}")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
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
|
+
def cache_user_from_api(user_data)
|
|
112
|
+
user = Models::User.from_api(user_data)
|
|
113
|
+
@cache.set_user(@workspace.name, user.id, user.best_name, persist: true)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Slk
|
|
6
|
+
module Support
|
|
7
|
+
# Parses date strings into timestamps for Slack API queries
|
|
8
|
+
# Supports duration formats (1d, 7d, 1w, 1m) and ISO dates (YYYY-MM-DD)
|
|
9
|
+
class DateParser
|
|
10
|
+
DURATION_PATTERN = /\A(\d+)([dwm])\z/i
|
|
11
|
+
ISO_DATE_PATTERN = /\A\d{4}-\d{2}-\d{2}\z/
|
|
12
|
+
|
|
13
|
+
# Parse a date string and return a Unix timestamp
|
|
14
|
+
# @param input [String] duration (1d, 7d, 1w, 1m) or ISO date (YYYY-MM-DD)
|
|
15
|
+
# @return [Integer] Unix timestamp
|
|
16
|
+
# @raise [ArgumentError] if format is invalid
|
|
17
|
+
def self.parse(input)
|
|
18
|
+
new.parse(input)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Parse a date string and return a Slack-formatted timestamp (with microseconds)
|
|
22
|
+
# @param input [String] duration or ISO date
|
|
23
|
+
# @return [String] Slack timestamp like "1234567890.000000"
|
|
24
|
+
def self.to_slack_timestamp(input)
|
|
25
|
+
"#{parse(input)}.000000"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(input)
|
|
29
|
+
input = input.to_s.strip
|
|
30
|
+
|
|
31
|
+
case input
|
|
32
|
+
when DURATION_PATTERN
|
|
33
|
+
parse_duration(input)
|
|
34
|
+
when ISO_DATE_PATTERN
|
|
35
|
+
parse_iso_date(input)
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Invalid date format: #{input}. Use duration (1d, 7d, 1w, 1m) or ISO date (YYYY-MM-DD)"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def parse_duration(input)
|
|
44
|
+
match = input.match(DURATION_PATTERN)
|
|
45
|
+
amount = match[1].to_i
|
|
46
|
+
unit = match[2].downcase
|
|
47
|
+
|
|
48
|
+
seconds_ago = case unit
|
|
49
|
+
when 'd' then amount * 86_400 # days
|
|
50
|
+
when 'w' then amount * 7 * 86_400 # weeks
|
|
51
|
+
when 'm' then amount * 30 * 86_400 # months (approximate)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Time.now.to_i - seconds_ago
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_iso_date(input)
|
|
58
|
+
Time.parse("#{input} 00:00:00").to_i
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
raise ArgumentError, "Invalid ISO date: #{input}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
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
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Support
|
|
5
|
+
# Cross-platform utilities for OS-specific operations
|
|
6
|
+
module Platform
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def windows?
|
|
10
|
+
Gem.win_platform?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def macos?
|
|
14
|
+
RUBY_PLATFORM.include?('darwin')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def linux?
|
|
18
|
+
RUBY_PLATFORM.include?('linux')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Open a URL or file with the system's default handler.
|
|
22
|
+
# Uses: open (macOS), start (Windows), xdg-open (Linux)
|
|
23
|
+
def open_url(url)
|
|
24
|
+
if windows?
|
|
25
|
+
system('start', '', url)
|
|
26
|
+
elsif macos?
|
|
27
|
+
system('open', url)
|
|
28
|
+
else
|
|
29
|
+
system('xdg-open', url)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -2,22 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
module Slk
|
|
4
4
|
module Support
|
|
5
|
-
#
|
|
5
|
+
# Cross-platform paths for config and cache directories.
|
|
6
|
+
# Uses XDG Base Directory spec on Unix, APPDATA/LOCALAPPDATA on Windows.
|
|
6
7
|
class XdgPaths
|
|
8
|
+
WINDOWS = Gem.win_platform?
|
|
9
|
+
|
|
7
10
|
def config_dir
|
|
8
|
-
@config_dir ||= File.join(
|
|
9
|
-
ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config')),
|
|
10
|
-
'slk'
|
|
11
|
-
)
|
|
11
|
+
@config_dir ||= normalize_path(File.join(default_config_base, 'slk'))
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def cache_dir
|
|
15
|
-
@cache_dir ||= File.join(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
@cache_dir ||= normalize_path(File.join(default_cache_base, 'slk'))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def default_config_base
|
|
21
|
+
return ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config')) unless WINDOWS
|
|
22
|
+
|
|
23
|
+
ENV.fetch('APPDATA', File.join(Dir.home, 'AppData', 'Roaming'))
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
def default_cache_base
|
|
27
|
+
return ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')) unless WINDOWS
|
|
28
|
+
|
|
29
|
+
ENV.fetch('LOCALAPPDATA', File.join(Dir.home, 'AppData', 'Local'))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Normalize path separators to forward slashes for Dir.glob compatibility on Windows
|
|
33
|
+
def normalize_path(path)
|
|
34
|
+
WINDOWS ? path.tr('\\', '/') : path
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
public
|
|
38
|
+
|
|
21
39
|
def config_file(filename)
|
|
22
40
|
File.join(config_dir, filename)
|
|
23
41
|
end
|
data/lib/slk/version.rb
CHANGED
data/lib/slk.rb
CHANGED
|
@@ -32,6 +32,8 @@ module Slk
|
|
|
32
32
|
autoload :User, 'slk/models/user'
|
|
33
33
|
autoload :Channel, 'slk/models/channel'
|
|
34
34
|
autoload :Preset, 'slk/models/preset'
|
|
35
|
+
autoload :SearchResult, 'slk/models/search_result'
|
|
36
|
+
autoload :SavedItem, 'slk/models/saved_item'
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
# Application services for configuration, caching, and API communication
|
|
@@ -39,6 +41,8 @@ module Slk
|
|
|
39
41
|
autoload :ApiClient, 'slk/services/api_client'
|
|
40
42
|
autoload :Configuration, 'slk/services/configuration'
|
|
41
43
|
autoload :TokenStore, 'slk/services/token_store'
|
|
44
|
+
autoload :TokenLoader, 'slk/services/token_loader'
|
|
45
|
+
autoload :TokenSaver, 'slk/services/token_saver'
|
|
42
46
|
autoload :CacheStore, 'slk/services/cache_store'
|
|
43
47
|
autoload :PresetStore, 'slk/services/preset_store'
|
|
44
48
|
autoload :Encryption, 'slk/services/encryption'
|
|
@@ -50,11 +54,14 @@ module Slk
|
|
|
50
54
|
autoload :UnreadMarker, 'slk/services/unread_marker'
|
|
51
55
|
autoload :TargetResolver, 'slk/services/target_resolver'
|
|
52
56
|
autoload :SetupWizard, 'slk/services/setup_wizard'
|
|
57
|
+
autoload :UserLookup, 'slk/services/user_lookup'
|
|
58
|
+
autoload :MessageResolver, 'slk/services/message_resolver'
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
# Output formatters for messages, durations, and emoji
|
|
56
62
|
module Formatters
|
|
57
63
|
autoload :Output, 'slk/formatters/output'
|
|
64
|
+
autoload :MarkdownOutput, 'slk/formatters/markdown_output'
|
|
58
65
|
autoload :DurationFormatter, 'slk/formatters/duration_formatter'
|
|
59
66
|
autoload :MentionReplacer, 'slk/formatters/mention_replacer'
|
|
60
67
|
autoload :EmojiReplacer, 'slk/formatters/emoji_replacer'
|
|
@@ -64,6 +71,9 @@ module Slk
|
|
|
64
71
|
autoload :ActivityFormatter, 'slk/formatters/activity_formatter'
|
|
65
72
|
autoload :AttachmentFormatter, 'slk/formatters/attachment_formatter'
|
|
66
73
|
autoload :BlockFormatter, 'slk/formatters/block_formatter'
|
|
74
|
+
autoload :SearchFormatter, 'slk/formatters/search_formatter'
|
|
75
|
+
autoload :SavedItemFormatter, 'slk/formatters/saved_item_formatter'
|
|
76
|
+
autoload :TextProcessor, 'slk/formatters/text_processor'
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
# CLI commands implementing user-facing functionality
|
|
@@ -77,12 +87,14 @@ module Slk
|
|
|
77
87
|
autoload :Unread, 'slk/commands/unread'
|
|
78
88
|
autoload :Catchup, 'slk/commands/catchup'
|
|
79
89
|
autoload :Activity, 'slk/commands/activity'
|
|
90
|
+
autoload :Search, 'slk/commands/search'
|
|
80
91
|
autoload :Preset, 'slk/commands/preset'
|
|
81
92
|
autoload :Workspaces, 'slk/commands/workspaces'
|
|
82
93
|
autoload :Cache, 'slk/commands/cache'
|
|
83
94
|
autoload :Emoji, 'slk/commands/emoji'
|
|
84
95
|
autoload :Config, 'slk/commands/config'
|
|
85
96
|
autoload :Help, 'slk/commands/help'
|
|
97
|
+
autoload :Later, 'slk/commands/later'
|
|
86
98
|
end
|
|
87
99
|
|
|
88
100
|
# Thin wrappers around Slack API endpoints
|
|
@@ -96,6 +108,8 @@ module Slk
|
|
|
96
108
|
autoload :Threads, 'slk/api/threads'
|
|
97
109
|
autoload :Usergroups, 'slk/api/usergroups'
|
|
98
110
|
autoload :Activity, 'slk/api/activity'
|
|
111
|
+
autoload :Search, 'slk/api/search'
|
|
112
|
+
autoload :Saved, 'slk/api/saved'
|
|
99
113
|
end
|
|
100
114
|
|
|
101
115
|
# Utility classes for paths, parsing, and helpers
|
|
@@ -108,5 +122,6 @@ module Slk
|
|
|
108
122
|
autoload :UserResolver, 'slk/support/user_resolver'
|
|
109
123
|
autoload :TextWrapper, 'slk/support/text_wrapper'
|
|
110
124
|
autoload :InteractivePrompt, 'slk/support/interactive_prompt'
|
|
125
|
+
autoload :DateParser, 'slk/support/date_parser'
|
|
111
126
|
end
|
|
112
127
|
end
|