openclacky 1.2.18 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +15 -20
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
- data/lib/clacky/media/base.rb +93 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +57 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +28 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +65 -50
- data/lib/clacky/server/http_server.rb +345 -14
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2038 -1147
- data/lib/clacky/web/app.js +22 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +94 -7
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +159 -31
- data/lib/clacky/web/index.html +175 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +114 -28
- data/lib/clacky/web/sessions.js +436 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +1 -1
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +7 -3
- data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
"
|
|
1662
|
+
"· "
|
|
1537
1663
|
end
|
|
1538
|
-
|
|
1539
|
-
#
|
|
1540
|
-
|
|
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:
|
|
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
|
-
|
|
597
|
+
# Rewrite markdown image syntax  → 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
|
|
606
|
+
if LOCAL_MEDIA_EXTENSIONS.include?(ext) && File.exist?(path)
|
|
595
607
|
encoded = CGI.escape(href)
|
|
596
608
|
""
|
|
597
609
|
else
|
|
598
|
-
|
|
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
|
data/lib/clacky/version.rb
CHANGED