openclacky 1.2.17 → 1.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/lib/clacky/agent/skill_manager.rb +1 -1
  4. data/lib/clacky/agent/time_machine.rb +256 -74
  5. data/lib/clacky/agent/tool_executor.rb +12 -0
  6. data/lib/clacky/agent.rb +21 -31
  7. data/lib/clacky/agent_config.rb +18 -0
  8. data/lib/clacky/cli.rb +55 -3
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
  10. data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
  11. data/lib/clacky/media/base.rb +125 -0
  12. data/lib/clacky/media/dashscope.rb +243 -0
  13. data/lib/clacky/media/gemini.rb +10 -0
  14. data/lib/clacky/media/generator.rb +75 -0
  15. data/lib/clacky/media/openai_compat.rb +160 -0
  16. data/lib/clacky/message_history.rb +12 -7
  17. data/lib/clacky/providers.rb +28 -0
  18. data/lib/clacky/rich_ui_controller.rb +3 -1
  19. data/lib/clacky/server/backup_manager.rb +200 -0
  20. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  21. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  22. data/lib/clacky/server/channel/channel_manager.rb +180 -81
  23. data/lib/clacky/server/http_server.rb +348 -15
  24. data/lib/clacky/server/scheduler.rb +19 -0
  25. data/lib/clacky/server/session_registry.rb +8 -4
  26. data/lib/clacky/session_manager.rb +40 -2
  27. data/lib/clacky/skill.rb +3 -1
  28. data/lib/clacky/tools/trash_manager.rb +14 -0
  29. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  30. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  31. data/lib/clacky/ui2/ui_controller.rb +150 -19
  32. data/lib/clacky/utils/file_processor.rb +75 -4
  33. data/lib/clacky/version.rb +1 -1
  34. data/lib/clacky/web/app.css +2038 -1147
  35. data/lib/clacky/web/app.js +22 -1
  36. data/lib/clacky/web/backup.js +119 -0
  37. data/lib/clacky/web/billing.js +94 -7
  38. data/lib/clacky/web/channels.js +81 -11
  39. data/lib/clacky/web/design-sample.css +247 -0
  40. data/lib/clacky/web/design-sample.html +127 -0
  41. data/lib/clacky/web/favicon.svg +16 -0
  42. data/lib/clacky/web/i18n.js +159 -31
  43. data/lib/clacky/web/index.html +175 -55
  44. data/lib/clacky/web/logo_nav_dark.png +0 -0
  45. data/lib/clacky/web/onboard.js +114 -28
  46. data/lib/clacky/web/sessions.js +436 -192
  47. data/lib/clacky/web/settings.js +21 -1
  48. data/lib/clacky/web/skills.js +6 -6
  49. data/lib/clacky/web/tasks.js +129 -61
  50. data/lib/clacky/web/utils.js +72 -0
  51. data/lib/clacky/web/ws-dispatcher.js +6 -0
  52. data/lib/clacky.rb +1 -0
  53. metadata +8 -3
  54. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
data/lib/clacky/skill.rb CHANGED
@@ -26,7 +26,7 @@ module Clacky
26
26
  model
27
27
  forbidden_tools
28
28
  auto_summarize
29
-
29
+ always-show
30
30
  ].freeze
31
31
 
32
32
  attr_reader :directory, :frontmatter, :source_path
@@ -35,6 +35,7 @@ module Clacky
35
35
  attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
36
36
  attr_reader :fork_agent, :model, :forbidden_tools, :auto_summarize
37
37
  attr_reader :brand_skill, :brand_config
38
+ attr_reader :always_show
38
39
 
39
40
  # Source location of this skill — set by SkillLoader after registration.
40
41
  # One of: :default, :global_claude, :global_clacky, :project_claude, :project_clacky, :brand
@@ -515,6 +516,7 @@ module Clacky
515
516
  @model = @frontmatter["model"]
516
517
  @forbidden_tools = @frontmatter["forbidden_tools"]
517
518
  @auto_summarize = @frontmatter["auto_summarize"]
519
+ @always_show = @frontmatter["always-show"]
518
520
  end
519
521
 
520
522
  # Sanitize and auto-correct frontmatter fields instead of raising on bad data.
@@ -470,6 +470,7 @@ module Clacky
470
470
  return false unless File.exist?(json_path)
471
471
 
472
472
  sm.send(:_hard_delete_session_with_chunks, json_path)
473
+ _delete_snapshots(session[:session_id])
473
474
  true
474
475
  end
475
476
 
@@ -492,7 +493,9 @@ module Clacky
492
493
  next unless Time.parse(trash_time.to_s) < cutoff
493
494
  end
494
495
 
496
+ sid = session[:session_id]
495
497
  sm.send(:_hard_delete_session_with_chunks, filepath)
498
+ _delete_snapshots(sid)
496
499
  deleted += 1
497
500
  end
498
501
 
@@ -501,6 +504,17 @@ module Clacky
501
504
 
502
505
  # ── private class helper ──────────────────────────────────────────
503
506
 
507
+ # Remove a session's Time Machine snapshots. Snapshots are pure
508
+ # undo-history caches keyed by full session_id, so they can be dropped
509
+ # whenever the session is permanently deleted.
510
+ def self._delete_snapshots(session_id)
511
+ return if session_id.to_s.empty?
512
+
513
+ dir = File.join(Dir.home, ".clacky", "snapshots", session_id.to_s)
514
+ FileUtils.rm_rf(dir) if Dir.exist?(dir)
515
+ end
516
+ private_class_method :_delete_snapshots
517
+
504
518
  def self._trash_sessions(sm)
505
519
  trash_dir = Clacky::TrashDirectory.sessions_trash_dir
506
520
  Dir.glob(File.join(trash_dir, "*.json")).filter_map do |filepath|
@@ -15,6 +15,7 @@ module Clacky
15
15
  SYSTEM_COMMANDS = [
16
16
  { command: "/clear", description: "Clear chat history and restart session" },
17
17
  { command: "/config", description: "Open configuration (models, API keys, settings)" },
18
+ { command: "/model", description: "Quickly switch the current model" },
18
19
  { command: "/undo", description: "Undo the last task and restore previous state" },
19
20
  { command: "/help", description: "Show help information" },
20
21
  { command: "/exit", description: "Exit the chat session" },
@@ -32,7 +32,11 @@ module Clacky
32
32
  # Should return { success: true } or { success: false, error: "message" }
33
33
  # @param on_close [Proc, nil] Optional callback to execute when modal closes (e.g., to re-render screen)
34
34
  # @return [Hash, nil] Hash of field values or selected value, or nil if cancelled
35
- def show(title:, fields: nil, choices: nil, validator: nil, on_close: nil)
35
+ # @param nav_keys [Boolean] when true, on an expandable choice returns
36
+ # { nav: :expand, value: } and ←/Esc returns { nav: :back }, letting the
37
+ # caller drive multi-page (drawer) navigation within one modal flow.
38
+ # @param instructions [String, nil] override the bottom hint line.
39
+ def show(title:, fields: nil, choices: nil, validator: nil, on_close: nil, initial_index: nil, nav_keys: false, instructions: nil)
36
40
  @title = title
37
41
  @mode = choices ? :menu : :form
38
42
  @fields = fields || []
@@ -40,10 +44,18 @@ module Clacky
40
44
  @values = {}
41
45
  @error_message = nil
42
46
  @selected_index = 0
47
+ @nav_keys = nav_keys
48
+ @instructions = instructions
43
49
 
44
- # For menu mode, find first non-disabled choice
50
+ # For menu mode, default to first non-disabled choice, unless the
51
+ # caller pinned an initial cursor position (e.g. Time Machine wants
52
+ # the cursor to land on the currently-active task).
45
53
  if @mode == :menu
46
- @selected_index = @choices.index { |c| !c[:disabled] } || 0
54
+ if initial_index && @choices[initial_index] && !@choices[initial_index][:disabled]
55
+ @selected_index = initial_index
56
+ else
57
+ @selected_index = @choices.index { |c| !c[:disabled] } || 0
58
+ end
47
59
  end
48
60
 
49
61
  # Adjust height based on mode
@@ -108,13 +120,23 @@ module Clacky
108
120
  when "\e" # Escape sequence
109
121
  seq = STDIN.read_nonblock(2) rescue ''
110
122
  if seq.empty?
111
- # Just Esc key - cancel
123
+ # Just Esc key. With nav_keys, Esc means "back" (the caller
124
+ # decides whether that's go-to-parent or cancel); otherwise cancel.
112
125
  print "\e[?25l"
113
- return nil
126
+ return @nav_keys ? { nav: :back } : nil
114
127
  elsif seq == '[A' # Up arrow
115
128
  move_menu_selection(-1)
116
129
  elsif seq == '[B' # Down arrow
117
130
  move_menu_selection(1)
131
+ elsif seq == '[C' && @nav_keys # Right arrow - expand
132
+ selected = @choices[@selected_index]
133
+ if selected && !selected[:disabled] && selected[:expandable]
134
+ print "\e[?25l"
135
+ return { nav: :expand, value: selected[:value] }
136
+ end
137
+ elsif seq == '[D' && @nav_keys # Left arrow - back
138
+ print "\e[?25l"
139
+ return { nav: :back }
118
140
  end
119
141
  when "\u0003" # Ctrl+C
120
142
  print "\e[?25l"
@@ -376,7 +398,12 @@ module Clacky
376
398
  text = @pastel.dim(choice_text)
377
399
  elsif is_selected
378
400
  marker = @pastel.bright_cyan("→ ")
379
- text = @pastel.bright_white(choice_text)
401
+ text = choice[:dim] ? @pastel.dim(choice_text) : @pastel.bright_white(choice_text)
402
+ elsif choice[:dim]
403
+ # Undone / abandoned-branch task: keep it visually de-emphasized
404
+ # even when not under the cursor.
405
+ marker = " "
406
+ text = @pastel.dim(choice_text)
380
407
  else
381
408
  marker = " "
382
409
  text = @pastel.white(choice_text)
@@ -394,7 +421,7 @@ module Clacky
394
421
 
395
422
  # Draw menu instructions
396
423
  private def draw_menu_instructions(row, col)
397
- instructions = "↑↓/jk: Navigate • Enter: Select • Esc/q: Cancel"
424
+ instructions = @instructions || "↑↓/jk: Navigate • Enter: Select • Esc/q: Cancel"
398
425
  padding = (@width - instructions.length - 2) / 2
399
426
  remaining = @width - padding - instructions.length - 2
400
427
 
@@ -149,7 +149,8 @@ module Clacky
149
149
  update_sessionbar
150
150
  end
151
151
 
152
- # Stop the UI controller
152
+ # Stop the UI controller. Keeps the screen by default so any final
153
+ # output (e.g. the "clacky -a <id>" resume hint) survives in scrollback.
153
154
  def stop(clear_screen: false)
154
155
  @running = false
155
156
  @layout.cleanup_screen(clear_screen: clear_screen)
@@ -959,6 +960,8 @@ module Clacky
959
960
  separator,
960
961
  "",
961
962
  theme.format_text("Commands:", :info),
963
+ " #{theme.format_text("/model", :success)} - Quickly switch the current model",
964
+ " #{theme.format_text("/config", :success)} - Configure models, API keys, settings",
962
965
  " #{theme.format_text("/clear", :success)} - Clear output and restart session",
963
966
  " #{theme.format_text("/exit", :success)} - Exit application",
964
967
  "",
@@ -1519,46 +1522,174 @@ module Clacky
1519
1522
  end
1520
1523
  end
1521
1524
 
1525
+ # Quick model switcher — lists configured model cards and returns the
1526
+ # picked card's stable id. Unlike show_config_modal this never mutates
1527
+ # config; it's a pure picker. All side effects live in the CLI layer.
1528
+ # @return [Hash, nil] { model_id: <id> } or nil if cancelled / no models
1529
+ # Two-level drawer picker for /model.
1530
+ #
1531
+ # Level 1 (card list): Enter selects a card and uses its default model
1532
+ # (clearing any sub-model overlay); → opens that card's sub-model drawer
1533
+ # (only for cards whose provider exposes >= 2 sub-models, marked "›").
1534
+ #
1535
+ # Level 2 (sub-model list): Enter pins the chosen sub-model (or Default);
1536
+ # ← / Esc returns to the card list.
1537
+ #
1538
+ # @param current_config [AgentConfig]
1539
+ # @param submodels_for [Proc] called with a card hash, returns its provider
1540
+ # sub-model names (or [] if none / single).
1541
+ # @return [Hash, nil] { model_id:, model_name: } where model_name is the
1542
+ # chosen sub-model (nil = the card's own default), or nil if cancelled.
1543
+ public def show_model_switch_modal(current_config, submodels_for)
1544
+ return nil if current_config.models.empty?
1545
+
1546
+ on_close = -> { @layout.rerender_all }
1547
+
1548
+ loop do
1549
+ card = show_model_card_level(current_config, submodels_for, on_close)
1550
+ return nil if card.nil?
1551
+
1552
+ # Plain card pick (Enter): use the card default, clear overlay.
1553
+ return { model_id: card[:model_id], model_name: nil } unless card[:expand]
1554
+
1555
+ # → opened the drawer: let the user pick a sub-model for this card.
1556
+ model = current_config.models.find { |m| m["id"] == card[:model_id] }
1557
+ drawer = {
1558
+ model_id: card[:model_id],
1559
+ card_model: card[:card_model],
1560
+ submodels: submodels_for.call(model) || [],
1561
+ current_overlay: current_config.session_model_overlay_name
1562
+ }
1563
+ sub = show_model_submodel_level(drawer, on_close)
1564
+ next if sub == :back # ← / Esc: back to the card list
1565
+
1566
+ return { model_id: card[:model_id], model_name: sub }
1567
+ end
1568
+ end
1569
+
1570
+ # Level 1: card list. Returns { model_id:, expand: bool } on Enter/→,
1571
+ # or nil if cancelled.
1572
+ private def show_model_card_level(current_config, submodels_for, on_close)
1573
+ current_overlay = current_config.session_model_overlay_name
1574
+
1575
+ choices = current_config.models.each_with_index.map do |model, idx|
1576
+ is_current = (idx == current_config.current_model_index)
1577
+ card_model = model["model"] || "unnamed"
1578
+ type_badge = case model["type"]
1579
+ when "default" then " [default]"
1580
+ when "lite" then " [lite]"
1581
+ else ""
1582
+ end
1583
+ expandable = (submodels_for.call(model) || []).length >= 2
1584
+
1585
+ marker = is_current ? "● " : " "
1586
+ arrow = expandable ? " ›" : ""
1587
+ {
1588
+ name: "#{marker}#{card_model}#{type_badge}#{arrow}",
1589
+ value: { model_id: model["id"], card_model: card_model },
1590
+ expandable: expandable
1591
+ }
1592
+ end
1593
+
1594
+ result = Components::ModalComponent.new.show(
1595
+ title: "Switch Model",
1596
+ choices: choices,
1597
+ nav_keys: true,
1598
+ instructions: "↑↓ move • Enter select • → submodels • Esc cancel",
1599
+ on_close: on_close
1600
+ )
1601
+
1602
+ return nil if result.nil? || (result.is_a?(Hash) && result[:nav] == :back)
1603
+
1604
+ if result.is_a?(Hash) && result[:nav] == :expand
1605
+ { model_id: result[:value][:model_id], card_model: result[:value][:card_model], expand: true }
1606
+ else
1607
+ { model_id: result[:model_id], card_model: result[:card_model], expand: false }
1608
+ end
1609
+ end
1610
+
1611
+ # Level 2: sub-model list for one card. Returns the chosen sub-model name,
1612
+ # nil (the card default), or :back to return to the card list.
1613
+ private def show_model_submodel_level(card, on_close)
1614
+ submodels = card[:submodels]
1615
+ current = card[:current_overlay]
1616
+ card_model = card[:card_model]
1617
+
1618
+ choices = [{
1619
+ name: "#{current.nil? ? '● ' : ' '}Default (#{card_model})",
1620
+ value: { model_name: nil }
1621
+ }]
1622
+ submodels.each do |name|
1623
+ choices << {
1624
+ name: "#{name == current ? '● ' : ' '}#{name}",
1625
+ value: { model_name: name }
1626
+ }
1627
+ end
1628
+
1629
+ result = Components::ModalComponent.new.show(
1630
+ title: card_model,
1631
+ choices: choices,
1632
+ nav_keys: true,
1633
+ instructions: "↑↓ move • Enter select • ←/Esc back",
1634
+ on_close: on_close
1635
+ )
1636
+
1637
+ return :back if result.nil? || (result.is_a?(Hash) && result[:nav] == :back)
1638
+
1639
+ result[:model_name]
1640
+ end
1641
+
1522
1642
  # Show time machine menu for task undo/redo
1523
1643
  # @param history [Array<Hash>] Task history with format: [{task_id, summary, status, has_branches}]
1524
1644
  # @return [Integer, nil] Selected task ID or nil if cancelled
1525
1645
  public def show_time_machine_menu(history)
1526
1646
  modal = Components::ModalComponent.new
1527
-
1647
+
1648
+ active_index = nil
1649
+
1528
1650
  # Build menu choices from history
1529
- choices = history.map do |task|
1530
- # Build visual indicator
1531
- indicator = if task[:status] == :current
1532
- "→ " # Current task
1533
- elsif task[:status] == :future
1534
- "↯ " # Future task (after undo)
1651
+ choices = history.each_with_index.map do |task, idx|
1652
+ # Status marker. The cursor itself draws a "→", so use distinct
1653
+ # glyphs here to avoid visual collision:
1654
+ # current · on-path history ✗ undone/abandoned branch
1655
+ marker = case task[:status]
1656
+ when :current
1657
+ active_index = idx
1658
+ "● "
1659
+ when :undone
1660
+ "✗ "
1535
1661
  else
1536
- " " # Past task
1662
+ "· "
1537
1663
  end
1538
-
1539
- # Add branch indicator
1540
- indicator += "⎇ " if task[:has_branches]
1541
-
1664
+
1665
+ # Branch indicator
1666
+ marker += "⎇ " if task[:has_branches]
1667
+
1542
1668
  # Truncate summary to fit on screen
1543
1669
  max_summary_length = 60
1544
1670
  summary = task[:summary]
1545
1671
  if summary.length > max_summary_length
1546
1672
  summary = summary[0...max_summary_length] + "..."
1547
1673
  end
1548
-
1674
+
1675
+ label = "#{marker}Task #{task[:task_id]}: #{summary}"
1676
+ label += " (undone)" if task[:status] == :undone
1677
+
1549
1678
  {
1550
- name: "#{indicator}Task #{task[:task_id]}: #{summary}",
1551
- value: task[:task_id]
1679
+ name: label,
1680
+ value: task[:task_id],
1681
+ dim: task[:status] == :undone
1552
1682
  }
1553
1683
  end
1554
-
1555
- # Show modal
1684
+
1685
+ # Show modal, landing the cursor on the currently-active task.
1556
1686
  result = modal.show(
1557
1687
  title: "Time Machine - Select Task to Navigate",
1558
1688
  choices: choices,
1689
+ initial_index: active_index,
1559
1690
  on_close: -> { @layout.rerender_all }
1560
1691
  )
1561
-
1692
+
1562
1693
  result # Return selected task_id or nil
1563
1694
  end
1564
1695
 
@@ -59,6 +59,15 @@ module Clacky
59
59
  ".jpeg" => "image/jpeg",
60
60
  ".gif" => "image/gif",
61
61
  ".webp" => "image/webp",
62
+ ".mp4" => "video/mp4",
63
+ ".webm" => "video/webm",
64
+ ".mov" => "video/quicktime",
65
+ ".wav" => "audio/wav",
66
+ ".mp3" => "audio/mpeg",
67
+ ".ogg" => "audio/ogg",
68
+ ".aac" => "audio/aac",
69
+ ".flac" => "audio/flac",
70
+ ".m4a" => "audio/mp4",
62
71
  ".pdf" => "application/pdf"
63
72
  }.freeze
64
73
 
@@ -528,6 +537,9 @@ module Clacky
528
537
 
529
538
  # Image extensions that can be inlined as data URLs in markdown content.
530
539
  LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
540
+ LOCAL_VIDEO_EXTENSIONS = %w[.mp4 .webm .mov].freeze
541
+ LOCAL_AUDIO_EXTENSIONS = %w[.wav .mp3 .ogg .aac .flac .m4a].freeze
542
+ LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS).freeze
531
543
 
532
544
  # Replace local image paths in markdown content with base64 data URLs.
533
545
  #
@@ -582,22 +594,81 @@ module Clacky
582
594
  def self.rewrite_local_image_urls(content)
583
595
  return content if content.nil? || content.empty?
584
596
 
585
- content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |match|
597
+ # Rewrite markdown image syntax ![alt](file:///path) proxy URL
598
+ content = content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |_match|
586
599
  alt = Regexp.last_match(1)
587
600
  href = Regexp.last_match(2)
588
601
 
589
- # Extract the filesystem path from the href
590
602
  path = href.sub(%r{\Afile://}, "")
591
603
  path = CGI.unescape(path)
592
604
 
593
605
  ext = File.extname(path).downcase
594
- if LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
606
+ if LOCAL_MEDIA_EXTENSIONS.include?(ext) && File.exist?(path)
595
607
  encoded = CGI.escape(href)
596
608
  "![#{alt}](/api/local-image?path=#{encoded})"
597
609
  else
598
- match # return original match unchanged
610
+ _match
599
611
  end
600
612
  end
613
+
614
+ # Rewrite <video src="file:///path/vid.mp4" ...> → proxy URL
615
+ content = content.gsub(/<video\b([^>]*)\bsrc="((?:file:\/\/)?\/[^"]+)"([^>]*)>/) do |_match|
616
+ pre = Regexp.last_match(1) || ""
617
+ href = Regexp.last_match(2)
618
+ post = Regexp.last_match(3) || ""
619
+
620
+ path = href.sub(%r{\Afile://}, "")
621
+ path = CGI.unescape(path)
622
+
623
+ ext = File.extname(path).downcase
624
+ if LOCAL_VIDEO_EXTENSIONS.include?(ext) && File.exist?(path)
625
+ encoded = CGI.escape(href)
626
+ "<video#{pre} src=\"/api/local-image?path=#{encoded}\"#{post}>"
627
+ else
628
+ _match
629
+ end
630
+ end
631
+
632
+ # Rewrite <audio src="file:///path/audio.wav" ...> → proxy URL
633
+ content = content.gsub(/<audio\b([^>]*)\bsrc="((?:file:\/\/)?\/[^"]+)"([^>]*)>/) do |_match|
634
+ pre = Regexp.last_match(1) || ""
635
+ href = Regexp.last_match(2)
636
+ post = Regexp.last_match(3) || ""
637
+
638
+ path = href.sub(%r{\Afile://}, "")
639
+ path = CGI.unescape(path)
640
+
641
+ ext = File.extname(path).downcase
642
+ if LOCAL_AUDIO_EXTENSIONS.include?(ext) && File.exist?(path)
643
+ encoded = CGI.escape(href)
644
+ "<audio#{pre} src=\"/api/local-image?path=#{encoded}\"#{post}>"
645
+ else
646
+ _match
647
+ end
648
+ end
649
+
650
+ # Rewrite video/audio markdown links [text](file:///path) → proxy URL
651
+ content = content.gsub(/(?<!!)\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |_match|
652
+ text = Regexp.last_match(1)
653
+ href = Regexp.last_match(2)
654
+
655
+ path = href.sub(%r{\Afile://}, "")
656
+ path = CGI.unescape(path)
657
+
658
+ ext = File.extname(path).downcase
659
+ if LOCAL_VIDEO_EXTENSIONS.include?(ext) || LOCAL_AUDIO_EXTENSIONS.include?(ext)
660
+ if File.exist?(path)
661
+ encoded = CGI.escape(href)
662
+ "[#{text}](/api/local-image?path=#{encoded})"
663
+ else
664
+ _match
665
+ end
666
+ else
667
+ _match
668
+ end
669
+ end
670
+
671
+ content
601
672
  end
602
673
  end
603
674
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.17"
4
+ VERSION = "1.3.0"
5
5
  end