collavre 0.21.0 → 0.22.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/app/assets/stylesheets/collavre/comments_popup.css +51 -3
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +91 -1
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +15 -0
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +6 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/list_controller.js +17 -2
- data/app/javascript/controllers/comments/presence_controller.js +56 -5
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +139 -2
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +5 -0
- data/app/models/collavre/user.rb +4 -0
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
- data/app/views/collavre/comments/_comment.html.erb +10 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/config/locales/channels.en.yml +2 -0
- data/config/locales/channels.ko.yml +2 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +3 -0
- data/config/locales/comments.ko.yml +3 -0
- data/config/locales/creatives.en.yml +2 -0
- data/config/locales/creatives.ko.yml +2 -0
- data/config/locales/integrations.en.yml +13 -2
- data/config/locales/integrations.ko.yml +13 -2
- data/config/routes.rb +12 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/lib/collavre/engine.rb +0 -1
- data/lib/collavre/integration_settings/key_definition.rb +6 -0
- data/lib/collavre/integration_settings/registry.rb +7 -2
- data/lib/collavre/version.rb +1 -1
- metadata +31 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class BaseController < ActionController::API
|
|
7
|
+
before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def authenticate!
|
|
12
|
+
token = extract_bearer_token
|
|
13
|
+
if token.blank?
|
|
14
|
+
render json: { error: "Missing authentication token" }, status: :unauthorized
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
19
|
+
unless access_token&.accessible?
|
|
20
|
+
render json: { error: "Invalid authentication token" }, status: :unauthorized
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user = Collavre::User.find_by(id: access_token.resource_owner_id)
|
|
25
|
+
unless user
|
|
26
|
+
render json: { error: "User not found" }, status: :unauthorized
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Collavre::Current.user = user
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract_bearer_token
|
|
34
|
+
auth_header = request.headers["Authorization"]
|
|
35
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
|
36
|
+
|
|
37
|
+
auth_header.sub("Bearer ", "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_user
|
|
41
|
+
Collavre::Current.user
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -8,7 +8,7 @@ module Collavre
|
|
|
8
8
|
return head :forbidden
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
blob
|
|
11
|
+
purge_unless_referenced(blob)
|
|
12
12
|
head :no_content
|
|
13
13
|
rescue ActiveRecord::RecordNotFound
|
|
14
14
|
head :not_found
|
|
@@ -19,10 +19,38 @@ module Collavre
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
+
# A blob can be shared across creatives (description HTML copied between
|
|
23
|
+
# them). The editor fires this DELETE for removed nodes after its PATCH, so
|
|
24
|
+
# purging unconditionally could delete a blob another creative still
|
|
25
|
+
# references, 404-ing its description. Only purge a true orphan.
|
|
26
|
+
def purge_unless_referenced(blob)
|
|
27
|
+
signed_id = blob.signed_id
|
|
28
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
|
|
29
|
+
|
|
30
|
+
return if Creative.where("description LIKE ?", pattern).exists?
|
|
31
|
+
return if ActiveStorage::Attachment.where(blob_id: blob.id).exists?
|
|
32
|
+
|
|
33
|
+
blob.purge
|
|
34
|
+
end
|
|
35
|
+
|
|
22
36
|
def authorized_to_purge?(blob)
|
|
23
37
|
return false unless Current.user
|
|
24
38
|
|
|
25
|
-
attachment_owned_by_current_user?(blob) ||
|
|
39
|
+
attachment_owned_by_current_user?(blob) ||
|
|
40
|
+
editable_creative_reference?(blob) ||
|
|
41
|
+
orphan_blob?(blob)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Authorize purging a true orphan (attached to nothing, in no description).
|
|
45
|
+
# Covers a node removed before its blob was ever attached (removed-then-save,
|
|
46
|
+
# so reconcile never sees it), which can prove neither ownership nor a
|
|
47
|
+
# writable reference and would otherwise 403, stranding the blob. Safe: any
|
|
48
|
+
# in-use blob still fails this check. Mirrors purge_unless_referenced.
|
|
49
|
+
def orphan_blob?(blob)
|
|
50
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(blob.signed_id)}%"
|
|
51
|
+
|
|
52
|
+
!ActiveStorage::Attachment.where(blob_id: blob.id).exists? &&
|
|
53
|
+
!Creative.where("description LIKE ?", pattern).exists?
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
def attachment_owned_by_current_user?(blob)
|
|
@@ -6,7 +6,7 @@ module Collavre
|
|
|
6
6
|
include Collavre::Comments::BatchOperations
|
|
7
7
|
|
|
8
8
|
before_action :set_creative
|
|
9
|
-
before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action, :download_images, :remove_image ]
|
|
9
|
+
before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :deny, :update_action, :download_images, :remove_image ]
|
|
10
10
|
|
|
11
11
|
def fullscreen
|
|
12
12
|
# Render the creative index page with comments popup auto-opened in fullscreen.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Bearer-only multipart upload endpoint for agents/CLI. Creates an
|
|
4
|
+
# ActiveStorage blob from the uploaded bytes, embeds the matching node into
|
|
5
|
+
# the Creative's description, and lets after_save reconcile attach it to
|
|
6
|
+
# creative.files. No server-local paths, no session/CSRF.
|
|
7
|
+
class AttachmentsController < Collavre::ApplicationController
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
allow_unauthenticated_access
|
|
11
|
+
skip_forgery_protection
|
|
12
|
+
before_action :authenticate_bearer!
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
creative = Collavre::Creative.find_by(id: params[:creative_id])
|
|
16
|
+
return render(json: { error: "Creative not found" }, status: :not_found) unless creative
|
|
17
|
+
|
|
18
|
+
unless creative.has_permission?(Current.user, :write)
|
|
19
|
+
return render(json: { error: "No write permission" }, status: :forbidden)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless creative.attachments_embeddable?
|
|
23
|
+
return render(json: { error: "Cannot attach files to GitHub-synced content" }, status: :unprocessable_entity)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
file = params[:file]
|
|
27
|
+
return render(json: { error: "No file provided" }, status: :unprocessable_entity) unless file.respond_to?(:read)
|
|
28
|
+
|
|
29
|
+
io = file.to_io
|
|
30
|
+
content_type = resolved_content_type(file, io)
|
|
31
|
+
io.rewind
|
|
32
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
33
|
+
io: io,
|
|
34
|
+
filename: file.original_filename,
|
|
35
|
+
content_type: content_type
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
creative.embed_attachment_blob!(blob)
|
|
39
|
+
|
|
40
|
+
render json: {
|
|
41
|
+
signed_id: blob.signed_id,
|
|
42
|
+
filename: blob.filename.to_s,
|
|
43
|
+
content_type: blob.content_type,
|
|
44
|
+
byte_size: blob.byte_size,
|
|
45
|
+
url: public_asset_url(blob)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The bundled `collavre` CLI sends application/octet-stream for every part,
|
|
52
|
+
# so treat octet-stream (and blank) as "unknown" and sniff via Marcel —
|
|
53
|
+
# otherwise png/mp4 render as generic download links, not inline media.
|
|
54
|
+
def resolved_content_type(file, io)
|
|
55
|
+
declared = file.content_type.presence
|
|
56
|
+
return declared if declared && declared != "application/octet-stream"
|
|
57
|
+
|
|
58
|
+
Marcel::MimeType.for(io, name: file.original_filename).presence ||
|
|
59
|
+
"application/octet-stream"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolve a Doorkeeper bearer token to Current.user. The MCP middleware
|
|
63
|
+
# only does this for /mcp paths, so this endpoint authenticates itself.
|
|
64
|
+
def authenticate_bearer!
|
|
65
|
+
token_string = Doorkeeper::OAuth::Token.from_request(
|
|
66
|
+
request, *Doorkeeper.configuration.access_token_methods
|
|
67
|
+
)
|
|
68
|
+
token = Doorkeeper::AccessToken.by_token(token_string) if token_string.present?
|
|
69
|
+
user = Collavre::User.find_by(id: token.resource_owner_id) if token&.accessible?
|
|
70
|
+
|
|
71
|
+
if user
|
|
72
|
+
Current.user = user
|
|
73
|
+
else
|
|
74
|
+
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -563,12 +563,102 @@ module Collavre
|
|
|
563
563
|
|
|
564
564
|
def serialize_creatives(collection)
|
|
565
565
|
if params[:simple].present?
|
|
566
|
-
|
|
566
|
+
# Preload origins so effective_origin (used per linked-shell row in both
|
|
567
|
+
# children_presence_set and the origin_id mapping below) resolves from
|
|
568
|
+
# memory instead of firing a query per shell. Only browse can hold shells;
|
|
569
|
+
# search is scoped to origin_id: nil, so this is a no-op there.
|
|
570
|
+
ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
|
|
571
|
+
children_ids = children_presence_set(collection)
|
|
572
|
+
searching = params[:search].present?
|
|
573
|
+
breadcrumbs = searching ? ::Creatives::BreadcrumbResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
|
|
574
|
+
# For hits routed through a linked shell, the path to expand in the
|
|
575
|
+
# user's own tree (local folders -> shell) so a breadcrumb jump can
|
|
576
|
+
# reach a shell nested under a collapsed folder.
|
|
577
|
+
reveal_paths = searching ? ::Creatives::RevealPathResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
|
|
578
|
+
collection.map do |c|
|
|
579
|
+
item = {
|
|
580
|
+
id: c.id,
|
|
581
|
+
description: c.effective_description(nil, false),
|
|
582
|
+
progress: c.progress,
|
|
583
|
+
has_children: children_ids.include?(c.id)
|
|
584
|
+
}
|
|
585
|
+
reveal = reveal_paths[c.id]
|
|
586
|
+
path = breadcrumbs[c.id]
|
|
587
|
+
if path.present?
|
|
588
|
+
item[:path] = mask_unreachable_crumbs(path, reveal)
|
|
589
|
+
end
|
|
590
|
+
item[:reveal_path] = reveal if reveal.present?
|
|
591
|
+
# For linked shells, expose the effective origin id so the picker can
|
|
592
|
+
# map a search breadcrumb (origin ids) back to the rendered shell node.
|
|
593
|
+
item[:origin_id] = c.effective_origin.id if c.origin_id
|
|
594
|
+
item
|
|
595
|
+
end
|
|
567
596
|
else
|
|
568
597
|
collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
|
|
569
598
|
end
|
|
570
599
|
end
|
|
571
600
|
|
|
601
|
+
# A breadcrumb jump expands the tree from a rendered root (or, for a shared
|
|
602
|
+
# subtree, a linked shell) down to the clicked crumb. If an ancestor above a
|
|
603
|
+
# crumb is itself unrenderable (unreadable, or archived while archived rows
|
|
604
|
+
# aren't shown) and no linked shell re-roots the path at/below it, the
|
|
605
|
+
# descendant can't be expanded either — so mask it too. BreadcrumbResolver
|
|
606
|
+
# masks the unrenderable ancestor itself; this masks everything downstream of
|
|
607
|
+
# it on the plain origin chain, matching exactly what the tree can render.
|
|
608
|
+
#
|
|
609
|
+
# `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
|
|
610
|
+
# a key re-roots navigation through its own shell (the client anchors at the
|
|
611
|
+
# nearest reveal entry at/above the clicked crumb), so it clears the block for
|
|
612
|
+
# itself and its descendants regardless of an archived/unreadable origin
|
|
613
|
+
# above it.
|
|
614
|
+
def mask_unreachable_crumbs(path, reveal)
|
|
615
|
+
blocked = false
|
|
616
|
+
path.map do |crumb|
|
|
617
|
+
if reveal&.key?(crumb[:id])
|
|
618
|
+
blocked = false
|
|
619
|
+
crumb
|
|
620
|
+
elsif crumb[:restricted]
|
|
621
|
+
blocked = true
|
|
622
|
+
crumb
|
|
623
|
+
elsif blocked
|
|
624
|
+
crumb.merge(restricted: true, description: nil)
|
|
625
|
+
else
|
|
626
|
+
crumb
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Batched "does this node have a child the user can actually browse to?"
|
|
632
|
+
# lookup so the picker tree renders expand toggles without an N+1.
|
|
633
|
+
#
|
|
634
|
+
# Must match exactly what expanding the node shows (IndexQuery#handle_id_query
|
|
635
|
+
# -> children_with_permission, minus archived unless show_archived), or the
|
|
636
|
+
# toggle either hides a reachable subtree or opens to an empty branch (and
|
|
637
|
+
# leaks that hidden children exist). Two alignments are needed:
|
|
638
|
+
# 1. Linked shells (origin_id set) store children under the effective
|
|
639
|
+
# origin (redirect_parent_to_origin + children->origin migration), so
|
|
640
|
+
# resolve each row to its effective origin before the lookup.
|
|
641
|
+
# 2. Apply the same archived + read-permission filters as the browse path.
|
|
642
|
+
def children_presence_set(collection)
|
|
643
|
+
return Set.new if collection.empty?
|
|
644
|
+
|
|
645
|
+
origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
646
|
+
|
|
647
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
648
|
+
candidates = candidates.where(archived_at: nil) unless params[:show_archived]
|
|
649
|
+
child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
|
|
650
|
+
return Set.new if child_rows.empty?
|
|
651
|
+
|
|
652
|
+
readable = ::Creatives::PermissionFilter
|
|
653
|
+
.new(user: Current.user).readable_ids(child_rows.map(&:first)).to_set
|
|
654
|
+
origins_with_visible_children = child_rows
|
|
655
|
+
.each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
|
|
656
|
+
|
|
657
|
+
collection.each_with_object(Set.new) do |c, set|
|
|
658
|
+
set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
572
662
|
def reorderer
|
|
573
663
|
@reorderer ||= ::Creatives::Reorderer.new(user: Current.user)
|
|
574
664
|
end
|
|
@@ -10,21 +10,29 @@ module Collavre
|
|
|
10
10
|
return head :forbidden
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
unless %w[running pending queued pending_approval].include?(task.status)
|
|
13
|
+
unless %w[running pending queued pending_approval delegated].include?(task.status)
|
|
14
14
|
return head :unprocessable_entity
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
was_delegated = task.status == "delegated"
|
|
17
18
|
task.update!(status: "cancelled")
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
# Delegated tasks have no active worker to run the ensure-block release,
|
|
21
|
+
# so free the agent slot and drain the topic queue here.
|
|
22
|
+
if was_delegated && task.agent
|
|
23
|
+
Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
|
|
24
|
+
Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
abort_agent_session(task)
|
|
20
28
|
|
|
21
29
|
head :ok
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
private
|
|
25
33
|
|
|
26
|
-
def
|
|
27
|
-
Collavre::
|
|
34
|
+
def abort_agent_session(task)
|
|
35
|
+
Collavre::AgentSessionAbort.call(agent: task.agent, task: task)
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
38
|
end
|
|
@@ -105,8 +105,23 @@ module Collavre
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
topic_id = topic.id
|
|
108
|
+
topic_name = topic.name
|
|
108
109
|
topic.destroy
|
|
109
110
|
|
|
111
|
+
# Best-effort orphaned-cron notice. Must never break the core deletion:
|
|
112
|
+
# if it raises, swallow + log so broadcast/head still run.
|
|
113
|
+
begin
|
|
114
|
+
Collavre::Topics::OrphanedCronNotifier.new(
|
|
115
|
+
topic_id: topic_id,
|
|
116
|
+
topic_name: topic_name
|
|
117
|
+
).call
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Rails.logger.error(
|
|
120
|
+
"[TopicsController#destroy] OrphanedCronNotifier failed for topic " \
|
|
121
|
+
"#{topic_id}: #{e.class} #{e.message}"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
110
125
|
broadcast_topic_event("deleted", topic_id: topic_id)
|
|
111
126
|
head :no_content
|
|
112
127
|
end
|
|
@@ -4,22 +4,15 @@ module Collavre
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
def approve
|
|
7
|
+
# Claude Channel permission prompts reuse the approval comment UI but the
|
|
8
|
+
# tool runs inside the remote Claude Code process — never execute it
|
|
9
|
+
# server-side. Approving relays an "allow" decision to the suspended
|
|
10
|
+
# session instead of invoking the ActionExecutor.
|
|
11
|
+
return decide_claude_channel_permission(:allow) if @comment.claude_channel_permission?
|
|
12
|
+
|
|
7
13
|
status = @comment.approval_status(Current.user)
|
|
8
14
|
if status != :ok
|
|
9
|
-
|
|
10
|
-
when :invalid_action_format then "collavre.comments.approve_invalid_format"
|
|
11
|
-
when :missing_action then "collavre.comments.approve_missing_action"
|
|
12
|
-
when :missing_approver then "collavre.comments.approve_missing_approver"
|
|
13
|
-
when :admin_required then "collavre.comments.approve_admin_required"
|
|
14
|
-
else "collavre.comments.approve_not_allowed"
|
|
15
|
-
end
|
|
16
|
-
http_status = case status
|
|
17
|
-
when :invalid_action_format, :missing_action, :missing_approver
|
|
18
|
-
:unprocessable_entity
|
|
19
|
-
else
|
|
20
|
-
:forbidden
|
|
21
|
-
end
|
|
22
|
-
render json: { error: I18n.t(error_key) }, status: http_status and return
|
|
15
|
+
render_approval_status_error(status) and return
|
|
23
16
|
end
|
|
24
17
|
|
|
25
18
|
begin
|
|
@@ -31,6 +24,17 @@ module Collavre
|
|
|
31
24
|
end
|
|
32
25
|
end
|
|
33
26
|
|
|
27
|
+
# Reject a Claude Channel tool-permission prompt. There is no native
|
|
28
|
+
# equivalent (the native approval UI has approve-only; an un-approved
|
|
29
|
+
# action is simply left pending), so deny is exclusive to these comments.
|
|
30
|
+
def deny
|
|
31
|
+
unless @comment.claude_channel_permission?
|
|
32
|
+
render json: { error: I18n.t("collavre.comments.approve_not_allowed") }, status: :forbidden and return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
decide_claude_channel_permission(:deny)
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
def update_action
|
|
35
39
|
# Initial checks outside the lock
|
|
36
40
|
executed_error = false
|
|
@@ -108,6 +112,45 @@ module Collavre
|
|
|
108
112
|
rescue ::Comments::ActionValidator::ValidationError => e
|
|
109
113
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
110
114
|
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Resolve a Claude Channel permission prompt: gate on the approver, record
|
|
119
|
+
# the decision atomically (idempotent against double-clicks), relay it to
|
|
120
|
+
# the suspended session, and re-render the now-decided comment.
|
|
121
|
+
def decide_claude_channel_permission(behavior)
|
|
122
|
+
status = @comment.approval_status(Current.user)
|
|
123
|
+
if status != :ok
|
|
124
|
+
render_approval_status_error(status) and return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
@comment.decide_claude_channel_permission!(behavior, by: Current.user)
|
|
129
|
+
rescue Comment::ClaudeChannelPermission::AlreadyDecided
|
|
130
|
+
render json: { error: I18n.t("collavre.comments.approve_already_executed") }, status: :unprocessable_entity and return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@comment.broadcast_claude_channel_permission_decision(behavior)
|
|
134
|
+
@comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
|
|
135
|
+
render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def render_approval_status_error(status)
|
|
139
|
+
error_key = case status
|
|
140
|
+
when :invalid_action_format then "collavre.comments.approve_invalid_format"
|
|
141
|
+
when :missing_action then "collavre.comments.approve_missing_action"
|
|
142
|
+
when :missing_approver then "collavre.comments.approve_missing_approver"
|
|
143
|
+
when :admin_required then "collavre.comments.approve_admin_required"
|
|
144
|
+
else "collavre.comments.approve_not_allowed"
|
|
145
|
+
end
|
|
146
|
+
http_status = case status
|
|
147
|
+
when :invalid_action_format, :missing_action, :missing_approver
|
|
148
|
+
:unprocessable_entity
|
|
149
|
+
else
|
|
150
|
+
:forbidden
|
|
151
|
+
end
|
|
152
|
+
render json: { error: I18n.t(error_key) }, status: http_status
|
|
153
|
+
end
|
|
111
154
|
end
|
|
112
155
|
end
|
|
113
156
|
end
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
$getRoot,
|
|
28
28
|
$getSelection,
|
|
29
29
|
$isElementNode,
|
|
30
|
+
$isLineBreakNode,
|
|
30
31
|
$isRangeSelection,
|
|
31
32
|
$isTextNode,
|
|
32
33
|
CAN_REDO_COMMAND,
|
|
@@ -47,9 +48,12 @@ import FileUploadPlugin, {
|
|
|
47
48
|
} from "./plugins/image_upload_plugin"
|
|
48
49
|
import { ImageNode } from "../lib/lexical/image_node"
|
|
49
50
|
import { AttachmentNode } from "../lib/lexical/attachment_node"
|
|
51
|
+
import { VideoNode } from "../lib/lexical/video_node"
|
|
50
52
|
import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
|
|
51
53
|
import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
|
|
52
54
|
import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
|
|
55
|
+
import { lexicalHtmlConfig, normalizeColoredContainers } from "../lib/lexical/color_import"
|
|
56
|
+
import { minimizeContentHtml } from "../lib/lexical/minimize_html"
|
|
53
57
|
import { updateResponsiveImages } from "../lib/responsive_images"
|
|
54
58
|
|
|
55
59
|
const URL_MATCHERS = [
|
|
@@ -120,43 +124,6 @@ function InitialContentPlugin({ html }) {
|
|
|
120
124
|
const [editor] = useLexicalComposerContext()
|
|
121
125
|
const lastApplied = useRef(null)
|
|
122
126
|
|
|
123
|
-
const collectDomTextStyles = useCallback((container) => {
|
|
124
|
-
const styles = []
|
|
125
|
-
if (!container) return styles
|
|
126
|
-
const ownerDocument = container.ownerDocument || document
|
|
127
|
-
const walker = ownerDocument.createTreeWalker(container, NodeFilter.SHOW_TEXT)
|
|
128
|
-
let current = walker.nextNode()
|
|
129
|
-
while (current) {
|
|
130
|
-
const parent = current.parentElement
|
|
131
|
-
let styleText = parent?.getAttribute?.("style") || ""
|
|
132
|
-
const colorAttr = parent?.dataset?.lexicalColor
|
|
133
|
-
const bgAttr = parent?.dataset?.lexicalBackgroundColor
|
|
134
|
-
|
|
135
|
-
if ((!styleText || !styleText.trim()) && (colorAttr || bgAttr)) {
|
|
136
|
-
const declarations = []
|
|
137
|
-
if (colorAttr) declarations.push(`color: ${colorAttr}`)
|
|
138
|
-
if (bgAttr) declarations.push(`background-color: ${bgAttr}`)
|
|
139
|
-
styleText = declarations.join("; ")
|
|
140
|
-
} else {
|
|
141
|
-
const lower = styleText.toLowerCase()
|
|
142
|
-
const fragments = []
|
|
143
|
-
if (colorAttr && !lower.includes("color:")) {
|
|
144
|
-
fragments.push(`color: ${colorAttr}`)
|
|
145
|
-
}
|
|
146
|
-
if (bgAttr && !lower.includes("background-color:")) {
|
|
147
|
-
fragments.push(`background-color: ${bgAttr}`)
|
|
148
|
-
}
|
|
149
|
-
if (fragments.length > 0) {
|
|
150
|
-
styleText = `${styleText}${styleText.trim().endsWith(";") || !styleText.trim() ? "" : ";"} ${fragments.join("; ")}`.trim()
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
styles.push(styleText || "")
|
|
155
|
-
current = walker.nextNode()
|
|
156
|
-
}
|
|
157
|
-
return styles
|
|
158
|
-
}, [])
|
|
159
|
-
|
|
160
127
|
useEffect(() => {
|
|
161
128
|
if (lastApplied.current === html) return
|
|
162
129
|
lastApplied.current = html
|
|
@@ -170,8 +137,15 @@ function InitialContentPlugin({ html }) {
|
|
|
170
137
|
// No more .trix-content wrapper
|
|
171
138
|
const container = doc.body
|
|
172
139
|
|
|
140
|
+
// Color / background-color are bound to text nodes during import by the
|
|
141
|
+
// colorAwareSpanImport html config (see lib/lexical/color_import). We no
|
|
142
|
+
// longer re-apply styles positionally after import, which used to drift
|
|
143
|
+
// onto the wrong text node whenever Lexical split or dropped text nodes.
|
|
173
144
|
syncLexicalStyleAttributes(container)
|
|
174
|
-
|
|
145
|
+
// Push color/background-color from non-span elements onto spans so the
|
|
146
|
+
// colorAwareSpanImport config binds it (the span importer can't see it
|
|
147
|
+
// otherwise). Must run after the sync above materializes data-lexical-*.
|
|
148
|
+
normalizeColoredContainers(container)
|
|
175
149
|
const nodes = $generateNodesFromDOM(editor, container)
|
|
176
150
|
|
|
177
151
|
// Filter out duplicate image nodes if any
|
|
@@ -192,24 +166,35 @@ function InitialContentPlugin({ html }) {
|
|
|
192
166
|
})
|
|
193
167
|
|
|
194
168
|
const appendedNodes = []
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
169
|
+
// Text nodes and inline elements (links, etc.) cannot live directly under
|
|
170
|
+
// the root. Minimized HTML stores a single line without its <p> wrapper, so
|
|
171
|
+
// a line like "Hello <strong>World</strong>" re-imports as several
|
|
172
|
+
// top-level inline nodes — group consecutive ones back into one paragraph
|
|
173
|
+
// so the line is not split apart.
|
|
174
|
+
let pendingParagraph = null
|
|
175
|
+
const flushPending = () => {
|
|
176
|
+
if (pendingParagraph) {
|
|
177
|
+
root.append(pendingParagraph)
|
|
178
|
+
appendedNodes.push(pendingParagraph)
|
|
179
|
+
pendingParagraph = null
|
|
202
180
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
181
|
+
}
|
|
182
|
+
uniqueNodes.forEach((node) => {
|
|
183
|
+
const isInlineLeaf =
|
|
184
|
+
$isTextNode(node) ||
|
|
185
|
+
$isLineBreakNode(node) ||
|
|
186
|
+
($isElementNode(node) && node.isInline())
|
|
187
|
+
if (isInlineLeaf) {
|
|
188
|
+
if (!pendingParagraph) pendingParagraph = $createParagraphNode()
|
|
189
|
+
pendingParagraph.append(node)
|
|
207
190
|
return
|
|
208
191
|
}
|
|
209
192
|
|
|
193
|
+
flushPending()
|
|
210
194
|
root.append(node)
|
|
211
195
|
appendedNodes.push(node)
|
|
212
196
|
})
|
|
197
|
+
flushPending()
|
|
213
198
|
|
|
214
199
|
if (root.getChildrenSize() === 0) {
|
|
215
200
|
const paragraph = $createParagraphNode()
|
|
@@ -217,12 +202,6 @@ function InitialContentPlugin({ html }) {
|
|
|
217
202
|
appendedNodes.push(paragraph)
|
|
218
203
|
}
|
|
219
204
|
|
|
220
|
-
const textNodes = root.getAllTextNodes()
|
|
221
|
-
textNodes.forEach((textNode, index) => {
|
|
222
|
-
const style = collectedStyles[index]
|
|
223
|
-
textNode.setStyle(style || "")
|
|
224
|
-
})
|
|
225
|
-
|
|
226
205
|
let lastChild = root.getLastChild()
|
|
227
206
|
while (
|
|
228
207
|
lastChild &&
|
|
@@ -237,7 +216,7 @@ function InitialContentPlugin({ html }) {
|
|
|
237
216
|
root.append($createParagraphNode())
|
|
238
217
|
}
|
|
239
218
|
})
|
|
240
|
-
}, [
|
|
219
|
+
}, [editor, html])
|
|
241
220
|
|
|
242
221
|
return null
|
|
243
222
|
}
|
|
@@ -942,7 +921,9 @@ function EditorInner({
|
|
|
942
921
|
anchor.setAttribute("target", "_blank")
|
|
943
922
|
anchor.setAttribute("rel", "noopener")
|
|
944
923
|
})
|
|
945
|
-
|
|
924
|
+
// Strip Lexical's verbose markup (extra <div>, white-space spans,
|
|
925
|
+
// duplicate format wrappers, single-line <p>) before persisting.
|
|
926
|
+
serialized = minimizeContentHtml(doc.body.firstElementChild)
|
|
946
927
|
})
|
|
947
928
|
// No Trix wrapper
|
|
948
929
|
onChange(serialized)
|
|
@@ -1019,12 +1000,14 @@ export default function InlineLexicalEditor({
|
|
|
1019
1000
|
LinkNode,
|
|
1020
1001
|
AutoLinkNode,
|
|
1021
1002
|
ImageNode,
|
|
1022
|
-
AttachmentNode
|
|
1003
|
+
AttachmentNode,
|
|
1004
|
+
VideoNode
|
|
1023
1005
|
],
|
|
1024
1006
|
onError(error) {
|
|
1025
1007
|
throw error
|
|
1026
1008
|
},
|
|
1027
|
-
theme
|
|
1009
|
+
theme,
|
|
1010
|
+
html: lexicalHtmlConfig
|
|
1028
1011
|
}),
|
|
1029
1012
|
[]
|
|
1030
1013
|
)
|
|
@@ -3,6 +3,7 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext
|
|
|
3
3
|
import { $getRoot } from "lexical"
|
|
4
4
|
import { $isImageNode } from "../../lib/lexical/image_node"
|
|
5
5
|
import { $isAttachmentNode } from "../../lib/lexical/attachment_node"
|
|
6
|
+
import { $isVideoNode } from "../../lib/lexical/video_node"
|
|
6
7
|
|
|
7
8
|
function extractSignedIdFromUrl(url) {
|
|
8
9
|
if (!url) return null
|
|
@@ -27,6 +28,8 @@ function getAllAttachmentUrls(editor) {
|
|
|
27
28
|
function traverse(node) {
|
|
28
29
|
if ($isImageNode(node)) {
|
|
29
30
|
urls.add(node.getSrc())
|
|
31
|
+
} else if ($isVideoNode(node)) {
|
|
32
|
+
urls.add(node.getSrc())
|
|
30
33
|
} else if ($isAttachmentNode(node)) {
|
|
31
34
|
urls.add(node.getSrc())
|
|
32
35
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
|
|
16
16
|
import { $createImageNode } from "../../lib/lexical/image_node"
|
|
17
17
|
import { $createAttachmentNode } from "../../lib/lexical/attachment_node"
|
|
18
|
+
import { $createVideoNode } from "../../lib/lexical/video_node"
|
|
18
19
|
|
|
19
20
|
export const INSERT_IMAGE_COMMAND = createCommand("INSERT_IMAGE_COMMAND")
|
|
20
21
|
export const INSERT_FILE_COMMAND = createCommand("INSERT_FILE_COMMAND")
|
|
@@ -25,6 +26,12 @@ function isImageFile(file) {
|
|
|
25
26
|
return /\.(bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name || "")
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function isVideoFile(file) {
|
|
30
|
+
if (!file) return false
|
|
31
|
+
if (file.type) return /^video\//i.test(file.type)
|
|
32
|
+
return /\.(mp4|webm|mov|m4v)$/i.test(file.name || "")
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export default function FileUploadPlugin({
|
|
29
36
|
onUploadStateChange,
|
|
30
37
|
directUploadUrl,
|
|
@@ -78,6 +85,11 @@ export default function FileUploadPlugin({
|
|
|
78
85
|
altText: attributes.filename,
|
|
79
86
|
maxWidth: 800 // Default max width
|
|
80
87
|
})
|
|
88
|
+
} else if (isVideoFile(file)) {
|
|
89
|
+
node = $createVideoNode({
|
|
90
|
+
src: url,
|
|
91
|
+
filename: attributes.filename
|
|
92
|
+
})
|
|
81
93
|
} else {
|
|
82
94
|
node = $createAttachmentNode({
|
|
83
95
|
src: url,
|