slk 0.12.1 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 709675ee0926af3aee4e201318382d1558d6a46e4ebd8158a6353ee0da49f670
4
- data.tar.gz: 8b82f996a687811516b17c6fe164a0dcedffc0e199de0eacadab61f37918955d
3
+ metadata.gz: cbd57363408aa79edfa6cbbe3e875034d2fbcb9e3c2a7ec8ea1e9acea30c9167
4
+ data.tar.gz: bf9ab8b668ad9d04191ddb9b63a1c502d3652059c3b5bbc16113dff1d94eb8bc
5
5
  SHA512:
6
- metadata.gz: 2ab37ace8886dd600f194faf26f96597e422f2d1252aaba09fafacf83fff77e549bd9bc69be059cd567c787a3b2b2e69e0fc84360ac42f186916c1ea8930fe5d
7
- data.tar.gz: 68f5dcd8ca42484c6c9783de5c35d02e95755046a69b1b0184b62ed4a84aaedfe0a2067821d18572e5b1f821ad98fe45a2af8b438f44930626df433a5806c5e3
6
+ metadata.gz: 71d05dc4a9f5b46c49e5a14cad5c5e9b79042ece234142bb16f53dde80da67a5beafd1a2af0f30ef33b2bb8ef5381d64393979e640c996810bb536005e9a8180
7
+ data.tar.gz: 49956b5f79adb5e03f4bac033109626a315f9a1326904e321d4dcf2e17d189d7ac500bb820e2dab52199243d916c204b6ecef94b0c445c8287d591bdd1bfaa20
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-09
11
+
12
+ ### Added
13
+
14
+ - `slk later` now lists each saved message's files (`[File: name]`) and attachment images (`[Image: name]`), and prints an "N files not downloaded" hint like `slk messages`. Pass `--fetch-attachments` to download them to `~/.cache/slk/files/` and show the local paths. With `--json`, it also adds a `file_paths` map (file ID to local path).
15
+ - With `--fetch-attachments`, `slk later` draws downloaded images inline under their `[File:]` line in Ghostty, Kitty, iTerm2 and WezTerm, including inside tmux. This uses [chafa](https://hpjansson.org/chafa/) when it's installed. In other terminals, without chafa, or when output is piped, only the local path is shown. Pass `--no-image-preview` or set `SLK_IMAGE_PREVIEW=0` to download without drawing images.
16
+
10
17
  ## [0.12.1] - 2026-10-06
11
18
 
12
19
  ### Fixed
@@ -9,6 +9,17 @@ module Slk
9
9
  # rubocop:disable Metrics/ClassLength
10
10
  class Later < Base
11
11
  include Support::InlineImages
12
+ include Support::AttachmentFetching
13
+ include Support::ImagePreview
14
+
15
+ BOOLEAN_FLAGS = {
16
+ '--completed' => :completed,
17
+ '--in-progress' => :in_progress,
18
+ '--counts' => :counts,
19
+ '--no-content' => :no_content,
20
+ '--no-image-preview' => :no_image_preview,
21
+ '--workspace-emoji' => :workspace_emoji
22
+ }.freeze
12
23
 
13
24
  def execute
14
25
  result = validate_options
@@ -86,6 +97,7 @@ module Slk
86
97
  unless @options[:no_content]
87
98
  message = fetch_message_content(workspace, item)
88
99
  json_item[:message] = message if message
100
+ add_json_file_paths(json_item, workspace, item, message)
89
101
  end
90
102
 
91
103
  json_item
@@ -93,7 +105,13 @@ module Slk
93
105
  end
94
106
  # rubocop:enable Metrics/MethodLength
95
107
 
96
- # rubocop:disable Metrics/MethodLength
108
+ def add_json_file_paths(json_item, workspace, item, message)
109
+ return unless @options[:fetch_attachments]
110
+
111
+ paths = attachment_paths_for(workspace, item, message)
112
+ json_item[:file_paths] = paths unless paths.empty?
113
+ end
114
+
97
115
  def display_formatted(workspace, items)
98
116
  if items.empty?
99
117
  puts 'No saved items found.'
@@ -101,37 +119,93 @@ module Slk
101
119
  end
102
120
 
103
121
  @fetch_failures = 0
104
- # Skip workspace emoji inline images in markdown mode (they're terminal escape sequences)
105
- if @options[:workspace_emoji] && inline_images_supported? && !@options[:markdown]
106
- display_with_workspace_emoji(workspace, items)
107
- else
108
- display_without_workspace_emoji(workspace, items)
109
- end
122
+ @displayed_messages = []
123
+ display_each_item(workspace, items)
110
124
  show_fetch_failure_summary
125
+ print_file_summary(@displayed_messages) unless @options[:fetch_attachments]
111
126
  end
112
- # rubocop:enable Metrics/MethodLength
113
127
 
114
- def display_without_workspace_emoji(workspace, items)
115
- formatter = build_formatter(output)
128
+ def display_each_item(workspace, items)
129
+ printer = buffered_display? ? method(:display_item_buffered) : method(:display_item_direct)
116
130
  items.each do |item|
117
131
  message = @options[:no_content] ? nil : fetch_message_content(workspace, item)
118
- formatter.display_item(item, workspace, message: message, width: display_width, truncate: @options[:truncate])
132
+ printer.call(item, workspace, message, attachment_paths_for(workspace, item, message))
119
133
  end
120
134
  end
121
135
 
122
- def display_with_workspace_emoji(workspace, items)
123
- # Capture output to StringIO, then reprint with workspace emoji
136
+ def display_item_direct(item, workspace, message, file_paths)
137
+ @direct_formatter ||= build_formatter(output)
138
+ format_item(@direct_formatter, item, workspace, message, file_paths)
139
+ end
140
+
141
+ # Capture one item's output, then reprint it line by line so workspace
142
+ # emoji and downloaded images can be drawn inline as each item arrives.
143
+ def display_item_buffered(item, workspace, message, file_paths)
124
144
  buffer = StringIO.new
125
- buffer_output = create_buffer_output(buffer)
126
- formatter = build_formatter(buffer_output)
145
+ format_item(build_formatter(create_buffer_output(buffer)), item, workspace, message, file_paths)
146
+ previews = preview_paths(file_paths)
147
+ buffer.string.each_line do |line|
148
+ print_buffered_line(line.chomp, workspace)
149
+ print_line_previews(line, previews)
150
+ end
151
+ end
127
152
 
128
- items.each do |item|
129
- message = @options[:no_content] ? nil : fetch_message_content(workspace, item)
130
- formatter.display_item(item, workspace, message: message, width: display_width, truncate: @options[:truncate])
153
+ def format_item(formatter, item, workspace, message, file_paths)
154
+ formatter.display_item(item, workspace, message: message, width: display_width,
155
+ truncate: @options[:truncate], file_paths: file_paths)
156
+ end
157
+
158
+ def print_buffered_line(line, workspace)
159
+ if workspace_emoji_images?
160
+ print_with_workspace_emoji(line, workspace)
161
+ else
162
+ puts line
131
163
  end
164
+ end
165
+
166
+ # Draw a preview under the [File: ...] / [Image: ...] line that names it
167
+ def print_line_previews(line, previews)
168
+ previews.each do |path|
169
+ next unless line.include?("[File: #{path}]") || line.include?("[Image: #{path}]")
132
170
 
133
- # Reprint each line with workspace emoji replacement
134
- buffer.string.each_line { |line| print_with_workspace_emoji(line.chomp, workspace) }
171
+ previews.delete(path)
172
+ print_image_preview(path)
173
+ end
174
+ end
175
+
176
+ def preview_paths(file_paths)
177
+ return [] unless attachment_previews?
178
+
179
+ file_paths.values.uniq.select { |path| previewable_image?(path) }
180
+ end
181
+
182
+ # Workspace emoji are terminal escape sequences, so skip them in markdown mode
183
+ def workspace_emoji_images?
184
+ @options[:workspace_emoji] && !@options[:markdown] && inline_images_supported?
185
+ end
186
+
187
+ def attachment_previews?
188
+ return false unless @options[:fetch_attachments]
189
+ return false if @options[:markdown] || @options[:quiet] || @options[:no_image_preview]
190
+
191
+ image_previews_supported?
192
+ end
193
+
194
+ def buffered_display?
195
+ workspace_emoji_images? || attachment_previews?
196
+ end
197
+
198
+ # Track the message for the "N files not downloaded" summary and, with
199
+ # --fetch-attachments, download its files/images to the local cache.
200
+ # @return [Hash] file_id / "att_<ts>_<idx>" => local path
201
+ def attachment_paths_for(workspace, item, message)
202
+ return {} unless message
203
+
204
+ model = Models::Message.from_api(message, channel_id: item.channel_id)
205
+ @displayed_messages&.push(model)
206
+ return {} unless @options[:fetch_attachments]
207
+
208
+ fetch_attachment_files([model], workspace)
135
209
  end
136
210
 
137
211
  def show_fetch_failure_summary
@@ -187,20 +261,19 @@ module Slk
187
261
  in_progress: false,
188
262
  counts: false,
189
263
  no_content: false,
264
+ no_image_preview: false,
190
265
  truncate: false,
191
266
  workspace_emoji: false
192
267
  )
193
268
  end
194
269
 
195
270
  def handle_option(arg, args, remaining)
196
- case arg
197
- when '-n', '--limit' then @options[:limit] = args.shift.to_i
198
- when '--completed' then @options[:completed] = true
199
- when '--in-progress' then @options[:in_progress] = true
200
- when '--counts' then @options[:counts] = true
201
- when '--no-content' then @options[:no_content] = true
202
- when '--workspace-emoji' then @options[:workspace_emoji] = true
203
- else super
271
+ if %w[-n --limit].include?(arg)
272
+ @options[:limit] = args.shift.to_i
273
+ elsif BOOLEAN_FLAGS.key?(arg)
274
+ @options[BOOLEAN_FLAGS[arg]] = true
275
+ else
276
+ super
204
277
  end
205
278
  end
206
279
 
@@ -221,6 +294,7 @@ module Slk
221
294
  def help_text
222
295
  help = Support::HelpFormatter.new('slk later [options]')
223
296
  help.description('Show saved "Later" items from Slack.')
297
+ help.note('Set SLK_IMAGE_PREVIEW=0 to always skip inline image previews.')
224
298
  add_options_section(help)
225
299
  help.render
226
300
  end
@@ -233,6 +307,8 @@ module Slk
233
307
  s.option('--in-progress', 'Show in-progress items')
234
308
  s.option('--counts', 'Show summary counts only')
235
309
  s.option('--no-content', 'Skip fetching message text')
310
+ s.option('--fetch-attachments', 'Download files to ~/.cache/slk/files/ (inline image previews need chafa)')
311
+ s.option('--no-image-preview', 'Download attachments but skip inline image previews')
236
312
  s.option('--workspace-emoji', 'Show workspace emoji as inline images')
237
313
  s.option('--no-emoji', 'Show :emoji: codes instead of unicode')
238
314
  s.option('--width N', 'Wrap text at N columns')
@@ -9,6 +9,7 @@ module Slk
9
9
  # rubocop:disable Metrics/ClassLength
10
10
  class Messages < Base
11
11
  include Support::InlineImages
12
+ include Support::AttachmentFetching
12
13
 
13
14
  # rubocop:disable Metrics/MethodLength
14
15
  def execute
@@ -346,28 +347,6 @@ module Slk
346
347
  end
347
348
  end
348
349
 
349
- def print_file_summary(messages)
350
- file_count = messages.sum { |m| m.files.size + downloadable_attachment_count(m) }
351
- return if file_count.zero?
352
-
353
- label = file_count == 1 ? '1 file' : "#{file_count} files"
354
- puts
355
- info("#{label} not downloaded. Use --fetch-attachments to download.")
356
- end
357
-
358
- def downloadable_attachment_count(message)
359
- message.attachments.count { |a| a['image_url'] || a['thumb_url'] }
360
- end
361
-
362
- def fetch_attachment_files(messages, workspace)
363
- paths = Support::XdgPaths.new
364
- downloader = Services::FileDownloader.new(
365
- cache_dir: paths.cache_dir,
366
- on_debug: ->(msg) { debug(msg) }
367
- )
368
- downloader.download_message_files(messages, workspace)
369
- end
370
-
371
350
  def find_workspace_emoji(workspace_name, emoji_name)
372
351
  return nil if emoji_name.empty?
373
352
 
@@ -3,6 +3,7 @@
3
3
  module Slk
4
4
  module Formatters
5
5
  # Formats saved/later items for terminal display
6
+ # rubocop:disable Metrics/ClassLength
6
7
  class SavedItemFormatter
7
8
  def initialize(output:, mention_replacer:, text_processor:, on_debug: nil)
8
9
  @output = output
@@ -13,7 +14,9 @@ module Slk
13
14
 
14
15
  # Display a single saved item
15
16
  # @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
+ # @param file_paths [Hash] file_id / "att_<ts>_<idx>" => local path for downloaded attachments
18
+ # rubocop:disable Metrics/ParameterLists
19
+ def display_item(item, workspace, message: nil, width: nil, truncate: false, file_paths: {})
17
20
  status_badge = format_status_badge(item)
18
21
  due_info = format_due_info(item)
19
22
 
@@ -22,9 +25,13 @@ module Slk
22
25
  @output.puts header_parts.join(' | ') unless header_parts.empty?
23
26
 
24
27
  # Message content
25
- display_message(message, workspace, width: width, truncate: truncate) if message
28
+ if message
29
+ display_message(message, workspace, width: width, truncate: truncate)
30
+ display_attachments(message, file_paths || {})
31
+ end
26
32
  @output.puts # blank line between items
27
33
  end
34
+ # rubocop:enable Metrics/ParameterLists
28
35
 
29
36
  private
30
37
 
@@ -113,6 +120,34 @@ module Slk
113
120
  lines[1..].each { |line| @output.puts " #{line.rstrip}" } if lines.length > 1
114
121
  end
115
122
 
123
+ def display_attachments(message, file_paths)
124
+ display_files(message['files'] || [], file_paths)
125
+ display_attachment_images(message['attachments'] || [], message['ts'], file_paths)
126
+ end
127
+
128
+ def display_files(files, file_paths)
129
+ files.each do |file|
130
+ label = file_paths[file['id']] || file['name'] || file['title'] || 'file'
131
+ @output.puts " #{@output.blue("[File: #{label}]")}"
132
+ end
133
+ end
134
+
135
+ def display_attachment_images(attachments, message_ts, file_paths)
136
+ attachments.each_with_index do |att, idx|
137
+ url = att['image_url'] || att['thumb_url']
138
+ next unless url
139
+
140
+ label = file_paths["att_#{message_ts}_#{idx}"] || att['title'] || image_filename(url)
141
+ @output.puts " #{@output.blue("[Image: #{label}]")}"
142
+ end
143
+ end
144
+
145
+ def image_filename(url)
146
+ File.basename(URI.parse(url).path)
147
+ rescue URI::InvalidURIError
148
+ 'image'
149
+ end
150
+
116
151
  def resolve_message_author(message, workspace)
117
152
  if message['user']
118
153
  @mentions.lookup_user_name(workspace, message['user']) || message['user']
@@ -140,5 +175,6 @@ module Slk
140
175
  text.length > max_length ? "#{text[0...max_length]}..." : text
141
176
  end
142
177
  end
178
+ # rubocop:enable Metrics/ClassLength
143
179
  end
144
180
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Support
5
+ # Shared --fetch-attachments behavior for commands that display messages.
6
+ # Expects the including command to provide #debug and #info.
7
+ module AttachmentFetching
8
+ private
9
+
10
+ # Download files and attachment images for the given messages.
11
+ # @return [Hash] file_id / "att_<ts>_<idx>" => local path
12
+ def fetch_attachment_files(messages, workspace)
13
+ downloader = Services::FileDownloader.new(
14
+ cache_dir: Support::XdgPaths.new.cache_dir,
15
+ on_debug: ->(msg) { debug(msg) }
16
+ )
17
+ downloader.download_message_files(messages, workspace)
18
+ end
19
+
20
+ def downloadable_file_count(messages)
21
+ messages.sum { |m| m.files.size + downloadable_attachment_count(m) }
22
+ end
23
+
24
+ def downloadable_attachment_count(message)
25
+ message.attachments.count { |a| a['image_url'] || a['thumb_url'] }
26
+ end
27
+
28
+ def print_file_summary(messages)
29
+ file_count = downloadable_file_count(messages)
30
+ return if file_count.zero?
31
+
32
+ label = file_count == 1 ? '1 file' : "#{file_count} files"
33
+ puts
34
+ info("#{label} not downloaded. Use --fetch-attachments to download.")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Slk
6
+ module Support
7
+ # Renders downloaded image attachments inline using chafa, which handles
8
+ # Kitty graphics (Ghostty/Kitty) and iTerm2 images, including tmux
9
+ # passthrough with Unicode placeholders so images scroll with the pane.
10
+ # Without a capable terminal or chafa, callers just show the file path.
11
+ # Set SLK_IMAGE_PREVIEW=0 (or false/no/off) to turn previews off.
12
+ # Expects Support::InlineImages to be included alongside.
13
+ module ImagePreview
14
+ PREVIEW_MAX_COLS = 100
15
+ PREVIEW_MAX_ROWS = 15
16
+ PREVIEWABLE_EXTENSIONS = %w[png jpg jpeg gif webp bmp tif tiff heic].freeze
17
+ DISABLED_VALUES = %w[0 false no off].freeze
18
+
19
+ private
20
+
21
+ def image_previews_supported?
22
+ return @image_previews_supported if defined?(@image_previews_supported)
23
+
24
+ @image_previews_supported = !image_previews_disabled_by_env? && $stdout.tty? &&
25
+ !image_preview_format.nil? && chafa_available?
26
+ end
27
+
28
+ def image_previews_disabled_by_env?
29
+ DISABLED_VALUES.include?(ENV.fetch('SLK_IMAGE_PREVIEW', '').strip.downcase)
30
+ end
31
+
32
+ def image_preview_format
33
+ if kitty_graphics_supported?
34
+ 'kitty'
35
+ elsif iterm2_protocol_supported?
36
+ 'iterm'
37
+ end
38
+ end
39
+
40
+ def previewable_image?(path)
41
+ PREVIEWABLE_EXTENSIONS.include?(File.extname(path.to_s).delete('.').downcase)
42
+ end
43
+
44
+ # Print an inline preview of the image at path, indented by indent columns.
45
+ # @return [Boolean] whether a preview was printed
46
+ def print_image_preview(path, indent: 2) # rubocop:disable Naming/PredicateMethod
47
+ return false unless previewable_image?(path) && File.exist?(path)
48
+
49
+ rendered = render_image_preview(path, indent)
50
+ return false unless rendered
51
+
52
+ $stdout.print(' ' * indent)
53
+ $stdout.print(rendered)
54
+ $stdout.print("\n") unless rendered.end_with?("\n", "\e[?25h")
55
+ $stdout.flush
56
+ true
57
+ end
58
+
59
+ def render_image_preview(path, indent)
60
+ stdout, _stderr, status = Open3.capture3(*chafa_args(path, indent))
61
+ status.success? && !stdout.empty? ? stdout : nil
62
+ rescue SystemCallError => e
63
+ debug("chafa failed: #{e.message}") if respond_to?(:debug, true)
64
+ nil
65
+ end
66
+
67
+ def chafa_args(path, indent)
68
+ args = ['chafa', "--format=#{image_preview_format}"]
69
+ args << '--passthrough=tmux' if in_tmux?
70
+ args + ["--size=#{preview_cols(indent)}x#{PREVIEW_MAX_ROWS}",
71
+ '--animate=off', '--probe=off', '--relative=off', path]
72
+ end
73
+
74
+ def preview_cols(indent)
75
+ [terminal_columns - indent - 1, PREVIEW_MAX_COLS].min.clamp(10, PREVIEW_MAX_COLS)
76
+ end
77
+
78
+ def terminal_columns
79
+ require 'io/console'
80
+ cols = IO.console&.winsize&.last.to_i
81
+ cols.positive? ? cols : 80
82
+ rescue StandardError
83
+ 80
84
+ end
85
+
86
+ def chafa_available?
87
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
88
+ File.executable?(File.join(dir, 'chafa'))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
data/lib/slk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Slk
4
- VERSION = '0.12.1'
4
+ VERSION = '0.13.0'
5
5
  end
data/lib/slk.rb CHANGED
@@ -194,6 +194,8 @@ module Slk
194
194
  autoload :XdgPaths, 'slk/support/xdg_paths'
195
195
  autoload :SlackUrlParser, 'slk/support/slack_url_parser'
196
196
  autoload :InlineImages, 'slk/support/inline_images'
197
+ autoload :AttachmentFetching, 'slk/support/attachment_fetching'
198
+ autoload :ImagePreview, 'slk/support/image_preview'
197
199
  autoload :HelpFormatter, 'slk/support/help_formatter'
198
200
  autoload :ErrorLogger, 'slk/support/error_logger'
199
201
  autoload :UserResolver, 'slk/support/user_resolver'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -136,11 +136,13 @@ files:
136
136
  - lib/slk/services/user_matcher.rb
137
137
  - lib/slk/services/user_picker.rb
138
138
  - lib/slk/services/who_target_resolver.rb
139
+ - lib/slk/support/attachment_fetching.rb
139
140
  - lib/slk/support/check_in_time.rb
140
141
  - lib/slk/support/date_parser.rb
141
142
  - lib/slk/support/decimal_timestamp.rb
142
143
  - lib/slk/support/error_logger.rb
143
144
  - lib/slk/support/help_formatter.rb
145
+ - lib/slk/support/image_preview.rb
144
146
  - lib/slk/support/inline_images.rb
145
147
  - lib/slk/support/interactive_prompt.rb
146
148
  - lib/slk/support/platform.rb