slk 0.1.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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +46 -0
  3. data/LICENSE +21 -0
  4. data/README.md +190 -0
  5. data/bin/slk +7 -0
  6. data/lib/slack_cli/api/activity.rb +28 -0
  7. data/lib/slack_cli/api/bots.rb +32 -0
  8. data/lib/slack_cli/api/client.rb +49 -0
  9. data/lib/slack_cli/api/conversations.rb +52 -0
  10. data/lib/slack_cli/api/dnd.rb +40 -0
  11. data/lib/slack_cli/api/emoji.rb +21 -0
  12. data/lib/slack_cli/api/threads.rb +44 -0
  13. data/lib/slack_cli/api/usergroups.rb +25 -0
  14. data/lib/slack_cli/api/users.rb +101 -0
  15. data/lib/slack_cli/cli.rb +118 -0
  16. data/lib/slack_cli/commands/activity.rb +292 -0
  17. data/lib/slack_cli/commands/base.rb +175 -0
  18. data/lib/slack_cli/commands/cache.rb +116 -0
  19. data/lib/slack_cli/commands/catchup.rb +484 -0
  20. data/lib/slack_cli/commands/config.rb +159 -0
  21. data/lib/slack_cli/commands/dnd.rb +143 -0
  22. data/lib/slack_cli/commands/emoji.rb +412 -0
  23. data/lib/slack_cli/commands/help.rb +76 -0
  24. data/lib/slack_cli/commands/messages.rb +317 -0
  25. data/lib/slack_cli/commands/presence.rb +107 -0
  26. data/lib/slack_cli/commands/preset.rb +239 -0
  27. data/lib/slack_cli/commands/status.rb +194 -0
  28. data/lib/slack_cli/commands/thread.rb +62 -0
  29. data/lib/slack_cli/commands/unread.rb +312 -0
  30. data/lib/slack_cli/commands/workspaces.rb +151 -0
  31. data/lib/slack_cli/formatters/duration_formatter.rb +28 -0
  32. data/lib/slack_cli/formatters/emoji_replacer.rb +143 -0
  33. data/lib/slack_cli/formatters/mention_replacer.rb +154 -0
  34. data/lib/slack_cli/formatters/message_formatter.rb +429 -0
  35. data/lib/slack_cli/formatters/output.rb +89 -0
  36. data/lib/slack_cli/models/channel.rb +52 -0
  37. data/lib/slack_cli/models/duration.rb +85 -0
  38. data/lib/slack_cli/models/message.rb +217 -0
  39. data/lib/slack_cli/models/preset.rb +73 -0
  40. data/lib/slack_cli/models/reaction.rb +54 -0
  41. data/lib/slack_cli/models/status.rb +57 -0
  42. data/lib/slack_cli/models/user.rb +56 -0
  43. data/lib/slack_cli/models/workspace.rb +52 -0
  44. data/lib/slack_cli/runner.rb +123 -0
  45. data/lib/slack_cli/services/api_client.rb +149 -0
  46. data/lib/slack_cli/services/cache_store.rb +198 -0
  47. data/lib/slack_cli/services/configuration.rb +74 -0
  48. data/lib/slack_cli/services/encryption.rb +51 -0
  49. data/lib/slack_cli/services/preset_store.rb +112 -0
  50. data/lib/slack_cli/services/reaction_enricher.rb +87 -0
  51. data/lib/slack_cli/services/token_store.rb +117 -0
  52. data/lib/slack_cli/support/error_logger.rb +28 -0
  53. data/lib/slack_cli/support/help_formatter.rb +139 -0
  54. data/lib/slack_cli/support/inline_images.rb +62 -0
  55. data/lib/slack_cli/support/slack_url_parser.rb +78 -0
  56. data/lib/slack_cli/support/user_resolver.rb +114 -0
  57. data/lib/slack_cli/support/xdg_paths.rb +37 -0
  58. data/lib/slack_cli/version.rb +5 -0
  59. data/lib/slack_cli.rb +91 -0
  60. metadata +103 -0
@@ -0,0 +1,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Formatters
5
+ class MessageFormatter
6
+ def initialize(output:, mention_replacer:, emoji_replacer:, cache_store:, api_client: nil, on_debug: nil)
7
+ @output = output
8
+ @mentions = mention_replacer
9
+ @emoji = emoji_replacer
10
+ @cache = cache_store
11
+ @api_client = api_client
12
+ @on_debug = on_debug
13
+ end
14
+
15
+ def format(message, workspace:, options: {})
16
+ username = resolve_username(message, workspace, options)
17
+ timestamp = format_timestamp(message.timestamp)
18
+ text = process_text(message.text, workspace, options)
19
+
20
+ # Build the header: [timestamp] username:
21
+ header = "#{@output.blue("[#{timestamp}]")} #{@output.bold(username)}:"
22
+ header_visible_width = visible_length("[#{timestamp}] #{username}: ")
23
+
24
+ # Preserve newlines in message text (just strip leading/trailing whitespace)
25
+ display_text = text.strip
26
+
27
+ # Wrap text if width is specified
28
+ width = options[:width]
29
+ if width && width > header_visible_width && !display_text.empty?
30
+ # First line has less space (width minus header), continuation lines use full width
31
+ first_line_width = width - header_visible_width
32
+ display_text = wrap_text(display_text, first_line_width, width)
33
+ end
34
+
35
+ # If no text but there are files, put first file inline with header
36
+ if display_text.empty? && message.has_files? && !options[:no_files]
37
+ first_file = message.files.first
38
+ file_name = first_file['name'] || 'file'
39
+ display_text = @output.blue("[File: #{file_name}]")
40
+ end
41
+
42
+ main_line = "#{header} #{display_text}"
43
+
44
+ lines = [main_line]
45
+
46
+ format_blocks(message, lines, workspace, options)
47
+ format_attachments(message, lines, workspace, options)
48
+ format_files(message, lines, options, skip_first: display_text.include?('[File:'))
49
+ format_reactions(message, lines, workspace, options)
50
+ format_thread_indicator(message, lines, options)
51
+
52
+ lines.join("\n")
53
+ end
54
+
55
+ def format_simple(message, workspace:, options: {})
56
+ username = resolve_username(message, workspace, options)
57
+ timestamp = format_timestamp(message.timestamp)
58
+ text = process_text(message.text, workspace, options)
59
+
60
+ reaction_text = ""
61
+ unless options[:no_reactions] || message.reactions.empty?
62
+ reaction_text = format_reaction_inline(message, options)
63
+ end
64
+
65
+ "#{@output.blue("[#{timestamp}]")} #{@output.bold(username)}: #{text}#{reaction_text}"
66
+ end
67
+
68
+ def format_reaction_inline(message, options)
69
+ parts = message.reactions.map do |r|
70
+ emoji = options[:no_emoji] ? r.emoji_code : (@emoji.lookup_emoji(r.name) || r.emoji_code)
71
+ "#{r.count} #{emoji}"
72
+ end
73
+ " [#{parts.join(", ")}]"
74
+ end
75
+
76
+ def format_json(message, workspace: nil, options: {})
77
+ reactions_json = message.reactions.map do |r|
78
+ reaction_hash = { name: r.name, count: r.count }
79
+
80
+ # Always return user objects with id, name (if available), and reacted_at (if available)
81
+ reaction_hash[:users] = r.users.map do |user_id|
82
+ user_hash = { id: user_id }
83
+
84
+ # Try to resolve display name
85
+ unless options[:no_names]
86
+ workspace_name = workspace&.name
87
+ if workspace_name
88
+ cached_name = @cache.get_user(workspace_name, user_id)
89
+ user_hash[:name] = cached_name if cached_name
90
+ end
91
+ end
92
+
93
+ # Add timestamp if available
94
+ if r.has_timestamps?
95
+ timestamp = r.timestamp_for(user_id)
96
+ if timestamp
97
+ user_hash[:reacted_at] = timestamp
98
+ user_hash[:reacted_at_iso8601] = Time.at(timestamp.to_f).iso8601
99
+ end
100
+ end
101
+
102
+ user_hash
103
+ end
104
+
105
+ reaction_hash
106
+ end
107
+
108
+ {
109
+ ts: message.ts,
110
+ user: message.user_id,
111
+ text: message.text,
112
+ reactions: reactions_json,
113
+ reply_count: message.reply_count,
114
+ thread_ts: message.thread_ts,
115
+ attachments: message.attachments,
116
+ files: message.files
117
+ }
118
+ end
119
+
120
+ private
121
+
122
+ def resolve_username(message, workspace, options = {})
123
+ # Skip lookups if --no-names
124
+ return message.user_id if options[:no_names]
125
+
126
+ # Try embedded profile first
127
+ return message.embedded_username if message.embedded_username
128
+
129
+ # Try cache
130
+ cached = @cache.get_user(workspace.name, message.user_id)
131
+ return cached if cached
132
+
133
+ # For bot IDs (start with B), try bots.info API
134
+ if message.user_id.start_with?("B") && @api_client
135
+ bot_name = lookup_bot_name(workspace, message.user_id)
136
+ return bot_name if bot_name
137
+ end
138
+
139
+ # Fall back to ID
140
+ message.user_id
141
+ end
142
+
143
+ def lookup_bot_name(workspace, bot_id)
144
+ bots_api = Api::Bots.new(@api_client, workspace, on_debug: @on_debug)
145
+ name = bots_api.get_name(bot_id)
146
+ if name
147
+ # Cache for future lookups (persist to disk)
148
+ @cache.set_user(workspace.name, bot_id, name, persist: true)
149
+ end
150
+ name
151
+ end
152
+
153
+ def format_timestamp(time)
154
+ time.strftime("%Y-%m-%d %H:%M")
155
+ end
156
+
157
+ def process_text(text, workspace, options)
158
+ result = text.dup
159
+
160
+ # Decode HTML entities (Slack encodes these)
161
+ result = decode_html_entities(result)
162
+
163
+ # Replace mentions
164
+ result = @mentions.replace(result, workspace)
165
+
166
+ # Replace emoji (unless disabled)
167
+ unless options[:no_emoji]
168
+ result = @emoji.replace(result, workspace)
169
+ end
170
+
171
+ result
172
+ end
173
+
174
+ def decode_html_entities(text)
175
+ text
176
+ .gsub('&', '&')
177
+ .gsub('&lt;', '<')
178
+ .gsub('&gt;', '>')
179
+ end
180
+
181
+ # Calculate visible length of text (excluding ANSI escape codes)
182
+ def visible_length(text)
183
+ text.gsub(/\e\[[0-9;]*m/, '').length
184
+ end
185
+
186
+ # Wrap text to width, handling first line differently and preserving existing newlines
187
+ def wrap_text(text, first_line_width, continuation_width)
188
+ result = []
189
+
190
+ text.each_line do |paragraph|
191
+ paragraph = paragraph.chomp
192
+ if paragraph.empty?
193
+ result << ''
194
+ next
195
+ end
196
+
197
+ # For each paragraph, wrap to width
198
+ # First paragraph's first line uses first_line_width, all other lines use continuation_width
199
+ current_first_width = result.empty? ? first_line_width : continuation_width
200
+ wrapped = wrap_paragraph(paragraph, current_first_width, continuation_width)
201
+ result << wrapped
202
+ end
203
+
204
+ result.join("\n")
205
+ end
206
+
207
+ # Wrap a single paragraph (no internal newlines)
208
+ def wrap_paragraph(text, first_width, rest_width)
209
+ words = text.split(/(\s+)/)
210
+ lines = []
211
+ current_line = ''
212
+ current_width = first_width
213
+
214
+ words.each do |word|
215
+ word_len = visible_length(word)
216
+
217
+ if current_line.empty?
218
+ current_line = word
219
+ elsif visible_length(current_line) + word_len <= current_width
220
+ current_line += word
221
+ else
222
+ lines << current_line
223
+ current_line = word.lstrip
224
+ current_width = rest_width
225
+ end
226
+ end
227
+
228
+ lines << current_line unless current_line.empty?
229
+
230
+ lines.join("\n")
231
+ end
232
+
233
+ def format_header(timestamp, username, message, options)
234
+ parts = []
235
+ parts << @output.blue("[#{timestamp}]")
236
+ parts << @output.bold(username)
237
+
238
+ if message.is_reply? && !options[:in_thread]
239
+ parts << @output.cyan("(reply)")
240
+ end
241
+
242
+ parts.join(" ")
243
+ end
244
+
245
+ def indent(text, spaces: 4)
246
+ prefix = " " * spaces
247
+ text.lines.map { |line| "#{prefix}#{line.chomp}" }.join("\n")
248
+ end
249
+
250
+ def format_attachments(message, lines, workspace, options)
251
+ return if message.attachments.empty?
252
+ return if options[:no_attachments]
253
+
254
+ message.attachments.each do |att|
255
+ att_text = att['text'] || att['fallback']
256
+ image_url = att['image_url'] || att['thumb_url']
257
+ title = att['title']
258
+
259
+ # Skip if no text and no image
260
+ next unless att_text || image_url
261
+
262
+ # Blank line before attachment
263
+ lines << ''
264
+
265
+ # Show author if available (for linked messages, bot messages, etc.)
266
+ author = att['author_name'] || att['author_subname']
267
+ lines << "> #{@output.bold(author)}:" if author
268
+
269
+ # Show text content if present
270
+ if att_text
271
+ # Process attachment text through the same pipeline as message text
272
+ processed_text = process_text(att_text, workspace, options)
273
+
274
+ # Wrap attachment text if width is specified (account for "> " prefix)
275
+ width = options[:width]
276
+ if width && width > 2
277
+ processed_text = wrap_text(processed_text, width - 2, width - 2)
278
+ end
279
+
280
+ # Prefix each line with > to show it's quoted/attachment content
281
+ processed_text.each_line do |line|
282
+ lines << "> #{line.chomp}"
283
+ end
284
+ end
285
+
286
+ # Show image info if present
287
+ if image_url
288
+ # Extract filename from URL or use title
289
+ filename = title || extract_filename_from_url(image_url)
290
+ lines << "> [Image: #{filename}]"
291
+ end
292
+ end
293
+ end
294
+
295
+ def format_blocks(message, lines, workspace, options)
296
+ return unless message.has_blocks?
297
+ return if options[:no_blocks]
298
+
299
+ # Extract text content from blocks (skip if it duplicates the main text)
300
+ block_texts = extract_block_texts(message.blocks)
301
+ return if block_texts.empty?
302
+
303
+ # Don't show blocks if they just repeat the main message text
304
+ main_text_normalized = message.text.gsub(/\s+/, ' ').strip.downcase
305
+ block_texts.reject! do |bt|
306
+ bt.gsub(/\s+/, ' ').strip.downcase == main_text_normalized
307
+ end
308
+ return if block_texts.empty?
309
+
310
+ # Blank line before blocks
311
+ lines << ''
312
+
313
+ block_texts.each do |block_text|
314
+ # Process text through mention/emoji pipeline
315
+ processed = process_text(block_text, workspace, options)
316
+
317
+ # Wrap if width specified (account for "> " prefix)
318
+ width = options[:width]
319
+ if width && width > 2
320
+ processed = wrap_text(processed, width - 2, width - 2)
321
+ end
322
+
323
+ # Prefix each line with >
324
+ processed.each_line do |line|
325
+ lines << "> #{line.chomp}"
326
+ end
327
+ end
328
+ end
329
+
330
+ def extract_block_texts(blocks)
331
+ return [] unless blocks.is_a?(Array)
332
+
333
+ blocks.filter_map do |block|
334
+ next unless block['type'] == 'section'
335
+
336
+ block.dig('text', 'text')
337
+ end
338
+ end
339
+
340
+ # Extract filename from a URL path, returning 'image' if parsing fails
341
+ def extract_filename_from_url(url)
342
+ File.basename(URI.parse(url).path)
343
+ rescue URI::InvalidURIError
344
+ 'image'
345
+ end
346
+
347
+ def format_files(message, lines, options, skip_first: false)
348
+ return if message.files.empty?
349
+ return if options[:no_files]
350
+
351
+ files_to_show = skip_first ? message.files[1..] : message.files
352
+ return if files_to_show.nil? || files_to_show.empty?
353
+
354
+ files_to_show.each do |file|
355
+ name = file['name'] || 'file'
356
+ lines << @output.blue("[File: #{name}]")
357
+ end
358
+ end
359
+
360
+ def format_reactions(message, lines, workspace, options)
361
+ return if message.reactions.empty?
362
+ return if options[:no_reactions]
363
+
364
+ # Check if we should show timestamps and if any reactions have them
365
+ if options[:reaction_timestamps] && message.reactions.any?(&:has_timestamps?)
366
+ format_reactions_with_timestamps(message, lines, workspace, options)
367
+ else
368
+ # Standard reaction display
369
+ reaction_text = message.reactions.map do |r|
370
+ emoji = options[:no_emoji] ? r.emoji_code : (@emoji.lookup_emoji(r.name) || r.emoji_code)
371
+ "#{r.count} #{emoji}"
372
+ end.join(" ")
373
+
374
+ lines << @output.yellow("[#{reaction_text}]")
375
+ end
376
+ end
377
+
378
+ def format_reactions_with_timestamps(message, lines, workspace, options)
379
+ workspace_name = workspace&.name
380
+
381
+ message.reactions.each do |reaction|
382
+ emoji = options[:no_emoji] ? reaction.emoji_code : (@emoji.lookup_emoji(reaction.name) || reaction.emoji_code)
383
+
384
+ # Group users with their timestamps
385
+ user_strings = reaction.users.map do |user_id|
386
+ username = resolve_user_for_reaction(user_id, workspace_name, options)
387
+ timestamp = reaction.timestamp_for(user_id)
388
+
389
+ if timestamp
390
+ time_str = format_reaction_time(timestamp)
391
+ "#{username} (#{time_str})"
392
+ else
393
+ username
394
+ end
395
+ end
396
+
397
+ lines << @output.yellow(" ↳ #{emoji} #{user_strings.join(', ')}")
398
+ end
399
+ end
400
+
401
+ def resolve_user_for_reaction(user_id, workspace_name, options)
402
+ return user_id if options[:no_names]
403
+
404
+ # Try cache lookup
405
+ if workspace_name
406
+ cached = @cache.get_user(workspace_name, user_id)
407
+ return cached if cached
408
+ end
409
+
410
+ # Fall back to user ID
411
+ user_id
412
+ end
413
+
414
+ def format_reaction_time(slack_timestamp)
415
+ time = Time.at(slack_timestamp.to_f)
416
+ time.strftime("%-I:%M %p") # e.g., "2:45 PM"
417
+ end
418
+
419
+ def format_thread_indicator(message, lines, options)
420
+ return unless message.has_thread?
421
+ return if options[:in_thread]
422
+ return if options[:no_threads]
423
+
424
+ reply_text = message.reply_count == 1 ? "1 reply" : "#{message.reply_count} replies"
425
+ lines << @output.cyan("[#{reply_text}]")
426
+ end
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Formatters
5
+ class Output
6
+ COLORS = {
7
+ red: "\e[0;31m",
8
+ green: "\e[0;32m",
9
+ yellow: "\e[0;33m",
10
+ blue: "\e[0;34m",
11
+ magenta: "\e[0;35m",
12
+ cyan: "\e[0;36m",
13
+ gray: "\e[0;90m",
14
+ bold: "\e[1m",
15
+ reset: "\e[0m"
16
+ }.freeze
17
+
18
+ attr_reader :verbose, :quiet
19
+
20
+ def initialize(io: $stdout, err: $stderr, color: nil, verbose: false, quiet: false)
21
+ @io = io
22
+ @err = err
23
+ @color = color.nil? ? io.tty? : color
24
+ @verbose = verbose
25
+ @quiet = quiet
26
+ end
27
+
28
+ def puts(message = "")
29
+ @io.puts(message) unless @quiet
30
+ end
31
+
32
+ def print(message)
33
+ @io.print(message) unless @quiet
34
+ end
35
+
36
+ def error(message)
37
+ @err.puts(colorize("#{red("Error:")} #{message}"))
38
+ end
39
+
40
+ def warn(message)
41
+ @err.puts(colorize("#{yellow("Warning:")} #{message}")) unless @quiet
42
+ end
43
+
44
+ def success(message)
45
+ puts(colorize("#{green("✓")} #{message}"))
46
+ end
47
+
48
+ def info(message)
49
+ puts(colorize(message))
50
+ end
51
+
52
+ def debug(message)
53
+ return unless @verbose
54
+
55
+ @err.puts(colorize("#{gray("[debug]")} #{message}"))
56
+ end
57
+
58
+ # Color helpers
59
+ def red(text) = wrap(:red, text)
60
+ def green(text) = wrap(:green, text)
61
+ def yellow(text) = wrap(:yellow, text)
62
+ def blue(text) = wrap(:blue, text)
63
+ def magenta(text) = wrap(:magenta, text)
64
+ def cyan(text) = wrap(:cyan, text)
65
+ def gray(text) = wrap(:gray, text)
66
+ def bold(text) = wrap(:bold, text)
67
+
68
+ def with_verbose(value)
69
+ self.class.new(io: @io, err: @err, color: @color, verbose: value, quiet: @quiet)
70
+ end
71
+
72
+ def with_quiet(value)
73
+ self.class.new(io: @io, err: @err, color: @color, verbose: @verbose, quiet: value)
74
+ end
75
+
76
+ private
77
+
78
+ def wrap(color, text)
79
+ return text.to_s unless @color
80
+
81
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
82
+ end
83
+
84
+ def colorize(text)
85
+ text
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Channel = Data.define(:id, :name, :is_private, :is_im, :is_mpim, :is_archived) do
6
+ def self.from_api(data)
7
+ new(
8
+ id: data["id"],
9
+ name: data["name"] || data["name_normalized"],
10
+ is_private: data["is_private"] || false,
11
+ is_im: data["is_im"] || false,
12
+ is_mpim: data["is_mpim"] || false,
13
+ is_archived: data["is_archived"] || false
14
+ )
15
+ end
16
+
17
+ def initialize(id:, name: nil, is_private: false, is_im: false, is_mpim: false, is_archived: false)
18
+ super(
19
+ id: id.to_s.freeze,
20
+ name: name&.freeze,
21
+ is_private: is_private,
22
+ is_im: is_im,
23
+ is_mpim: is_mpim,
24
+ is_archived: is_archived
25
+ )
26
+ end
27
+
28
+ def dm?
29
+ is_im || is_mpim
30
+ end
31
+
32
+ def public?
33
+ !is_private && !dm?
34
+ end
35
+
36
+ def display_name
37
+ return name if name
38
+
39
+ case id[0]
40
+ when "C" then "#channel"
41
+ when "G" then "#private"
42
+ when "D" then "DM"
43
+ else id
44
+ end
45
+ end
46
+
47
+ def to_s
48
+ dm? ? display_name : "##{name || id}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackCli
4
+ module Models
5
+ Duration = Data.define(:seconds) do
6
+ class << self
7
+ def parse(input)
8
+ return new(seconds: 0) if input.nil? || input.to_s.strip.empty?
9
+ return new(seconds: input.to_i) if input.to_s.match?(/^\d+$/)
10
+
11
+ total = 0
12
+ str = input.to_s.downcase
13
+
14
+ # Check for duplicate units (e.g., "1h1h" is invalid)
15
+ raise ArgumentError, "Duplicate 'h' unit in duration: #{input}" if str.scan(/\d+h/).length > 1
16
+ raise ArgumentError, "Duplicate 'm' unit in duration: #{input}" if str.scan(/\d+m/).length > 1
17
+ raise ArgumentError, "Duplicate 's' unit in duration: #{input}" if str.scan(/\d+s/).length > 1
18
+
19
+ if (match = str.match(/(\d+)h/))
20
+ total += match[1].to_i * 3600
21
+ end
22
+ if (match = str.match(/(\d+)m/))
23
+ total += match[1].to_i * 60
24
+ end
25
+ if (match = str.match(/(\d+)s/))
26
+ total += match[1].to_i
27
+ end
28
+
29
+ raise ArgumentError, "Invalid duration format: #{input}" if total.zero? && !str.match?(/^0/)
30
+
31
+ new(seconds: total)
32
+ end
33
+
34
+ def zero = new(seconds: 0)
35
+
36
+ def from_minutes(minutes)
37
+ new(seconds: minutes.to_i * 60)
38
+ end
39
+ end
40
+
41
+ def zero? = seconds.zero?
42
+
43
+ def to_minutes = (seconds / 60.0).ceil
44
+
45
+ def to_expiration
46
+ return 0 if zero?
47
+
48
+ Time.now.to_i + seconds
49
+ end
50
+
51
+ def to_s
52
+ return "" if zero?
53
+
54
+ parts = []
55
+ remaining = seconds
56
+
57
+ if remaining >= 3600
58
+ hours = remaining / 3600
59
+ parts << "#{hours}h"
60
+ remaining %= 3600
61
+ end
62
+
63
+ if remaining >= 60
64
+ minutes = remaining / 60
65
+ parts << "#{minutes}m"
66
+ remaining %= 60
67
+ end
68
+
69
+ if remaining > 0 && parts.empty?
70
+ parts << "#{remaining}s"
71
+ end
72
+
73
+ parts.join
74
+ end
75
+
76
+ def +(other)
77
+ Duration.new(seconds: seconds + other.seconds)
78
+ end
79
+
80
+ def -(other)
81
+ Duration.new(seconds: [seconds - other.seconds, 0].max)
82
+ end
83
+ end
84
+ end
85
+ end