slk 0.12.0 → 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: daeb158ed43f78f1945fe8b66d24d9e9566096d560d7981d49827006f02e95c0
4
- data.tar.gz: 30061321cb8d8e2476aa75897fe0830f557eb24f818360db74e9184a74eaaa72
3
+ metadata.gz: cbd57363408aa79edfa6cbbe3e875034d2fbcb9e3c2a7ec8ea1e9acea30c9167
4
+ data.tar.gz: bf9ab8b668ad9d04191ddb9b63a1c502d3652059c3b5bbc16113dff1d94eb8bc
5
5
  SHA512:
6
- metadata.gz: 7e69f27340cd5072b22e026eb8a429e1e3d5c9f91d8ae11d74f4c216fe7b133c322871e20d6566d9237db32dc7a9f769eaf142aaf43954cab0cdf36be37c965c
7
- data.tar.gz: da29b5d82c8eb18e1e25c4ce65da977c309770946fc29b62f5de95a10394b9739eb4771fce9f4691ffe9a2d8ca5edeee1b10839e319fd9c4b64db30289ecdd64
6
+ metadata.gz: 71d05dc4a9f5b46c49e5a14cad5c5e9b79042ece234142bb16f53dde80da67a5beafd1a2af0f30ef33b2bb8ef5381d64393979e640c996810bb536005e9a8180
7
+ data.tar.gz: 49956b5f79adb5e03f4bac033109626a315f9a1326904e321d4dcf2e17d189d7ac500bb820e2dab52199243d916c204b6ecef94b0c445c8287d591bdd1bfaa20
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ 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
+
17
+ ## [0.12.1] - 2026-10-06
18
+
19
+ ### Fixed
20
+
21
+ - `slk unread --json` now prints exactly one valid JSON document, with no workspace name lines mixed in. With one workspace (or `-w`), it prints `{"channels": [...], "dms": [...]}` alone. With several, it prints an object keyed by workspace name: `{"oddball": {...}, "dsva": {...}}`. This changes the output shape for multi-workspace callers.
22
+ - `slk unread -w WS` and `slk catchup -w WS` now limit themselves to that workspace. Before, they defaulted to all workspaces and ignored `-w`.
23
+ - `slk unread` text output shows workspace name headers only when it covers more than one workspace.
24
+
10
25
  ## [0.12.0] - 2026-09-30
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -354,10 +354,30 @@ rake test
354
354
  ### Releasing
355
355
 
356
356
  1. Update version in `lib/slk/version.rb`
357
- 2. Update `CHANGELOG.md` (move Unreleased to new version, add date)
358
- 3. Commit: `git commit -am "Release vX.Y.Z"`
359
- 4. Release to RubyGems: `rake release`
360
- 5. Create GitHub Release: `gh release create vX.Y.Z --generate-notes`
357
+ 2. Update `CHANGELOG.md`: add a `## [X.Y.Z] - YYYY-MM-DD` heading under `## [Unreleased]` so the pending entries move into the release
358
+ 3. Run `bundle install` so `Gemfile.lock` picks up the new version
359
+ 4. Verify: `bundle exec rake test && bundle exec rubocop`
360
+ 5. Commit version, changelog, and lockfile together, then push and wait for CI:
361
+ ```bash
362
+ git commit -am "chore(release): prepare vX.Y.Z"
363
+ git push origin master
364
+ gh run watch --exit-status
365
+ ```
366
+ 6. Build, tag, and push the tag:
367
+ ```bash
368
+ bundle exec rake build
369
+ git tag -a vX.Y.Z -m "Version X.Y.Z"
370
+ git push origin vX.Y.Z
371
+ ```
372
+ 7. Publish to RubyGems. `fnox exec` supplies `GEM_HOST_API_KEY` from `fnox.toml` (a stale key in `~/.gem/credentials` gets "Access Denied"); MFA is required, and `GEM_HOST_OTP_CODE` skips the prompt:
373
+ ```bash
374
+ fnox exec -- sh -c 'GEM_HOST_OTP_CODE="$(op item get Rubygems --otp)" gem push pkg/slk-X.Y.Z.gem'
375
+ ```
376
+ 8. Create the GitHub Release with the changelog entry as notes:
377
+ ```bash
378
+ awk '/^## \[X.Y.Z\]/{f=1;next} /^## \[/{f=0} f' CHANGELOG.md | sed '/./,$!d' > /tmp/notes.md
379
+ gh release create vX.Y.Z --title vX.Y.Z --notes-file /tmp/notes.md
380
+ ```
361
381
 
362
382
  ## License
363
383
 
@@ -90,6 +90,14 @@ module Slk
90
90
 
91
91
  private
92
92
 
93
+ # Catchup defaults to every workspace, but -w names one: let it win, or
94
+ # `slk catchup -w oddball` quietly walks (or batch-clears) all of them.
95
+ def target_workspaces
96
+ return [runner.workspace(@options[:workspace])] if @options[:workspace]
97
+
98
+ super
99
+ end
100
+
93
101
  def batch_catchup
94
102
  target_workspaces.each { |ws| batch_mark_workspace(ws) }
95
103
  0
@@ -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
 
@@ -95,22 +95,38 @@ module Slk
95
95
 
96
96
  private
97
97
 
98
- def show_unread
99
- target_workspaces.each do |workspace|
100
- puts output.bold(workspace.name) if @options[:all] || target_workspaces.size > 1
98
+ # Unread defaults to every workspace, but -w names one: let it win, or
99
+ # `slk unread -w oddball` quietly reports on all of them.
100
+ def target_workspaces
101
+ return [runner.workspace(@options[:workspace])] if @options[:workspace]
101
102
 
102
- unread_data = fetch_unread_data(workspace)
103
+ super
104
+ end
103
105
 
104
- if @options[:json]
105
- output_unread_json(workspace, unread_data)
106
- else
107
- display_unread(workspace, unread_data)
108
- end
106
+ def show_unread
107
+ return show_unread_json if @options[:json]
108
+
109
+ workspaces = target_workspaces
110
+ workspaces.each do |workspace|
111
+ puts output.bold(workspace.name) if workspaces.size > 1
112
+ display_unread(workspace, fetch_unread_data(workspace))
109
113
  end
110
114
 
111
115
  0
112
116
  end
113
117
 
118
+ # Exactly one JSON document: one workspace prints its {channels, dms}
119
+ # alone; several are keyed by workspace name. No name headers, so the
120
+ # output pipes straight into jq.
121
+ def show_unread_json
122
+ workspaces = target_workspaces
123
+ documents = workspaces.to_h do |workspace|
124
+ [workspace.name, unread_json(workspace, fetch_unread_data(workspace))]
125
+ end
126
+ output_json(workspaces.size == 1 ? documents.values.first : documents)
127
+ 0
128
+ end
129
+
114
130
  def fetch_unread_data(workspace)
115
131
  counts = runner.client_api(workspace.name).counts
116
132
  muted_ids = fetch_muted_ids(workspace)
@@ -135,11 +151,11 @@ module Slk
135
151
  .reject { |c| muted_ids.include?(c['id']) }
136
152
  end
137
153
 
138
- def output_unread_json(workspace, data)
139
- output_json({
140
- channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
141
- dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
142
- })
154
+ def unread_json(workspace, data)
155
+ {
156
+ channels: data[:unread_channels].map { |c| format_channel_json(workspace, c) },
157
+ dms: data[:unread_ims].map { |i| format_dm_json(workspace, i) }
158
+ }
143
159
  end
144
160
 
145
161
  def format_channel_json(workspace, channel)
@@ -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.0'
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.0
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