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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +51 -3
  3. data/app/assets/stylesheets/collavre/popup.css +148 -0
  4. data/app/channels/collavre/agent_channel.rb +205 -0
  5. data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
  6. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  7. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  8. data/app/controllers/collavre/attachments_controller.rb +30 -2
  9. data/app/controllers/collavre/comments_controller.rb +1 -1
  10. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  11. data/app/controllers/collavre/creatives_controller.rb +91 -1
  12. data/app/controllers/collavre/tasks_controller.rb +12 -4
  13. data/app/controllers/collavre/topics_controller.rb +15 -0
  14. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  15. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  16. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  17. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  18. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  19. data/app/javascript/controllers/comment_controller.js +6 -1
  20. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  21. data/app/javascript/controllers/comments/list_controller.js +17 -2
  22. data/app/javascript/controllers/comments/presence_controller.js +56 -5
  23. data/app/javascript/controllers/link_creative_controller.js +451 -29
  24. data/app/javascript/lib/api/creatives.js +13 -0
  25. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  26. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  27. data/app/javascript/lib/lexical/color_import.js +186 -0
  28. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  29. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  30. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  31. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  32. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  33. data/app/models/collavre/agent_subscription.rb +52 -0
  34. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  35. data/app/models/collavre/comment.rb +70 -5
  36. data/app/models/collavre/creative/describable.rb +139 -2
  37. data/app/models/collavre/creative_share.rb +1 -0
  38. data/app/models/collavre/task.rb +34 -5
  39. data/app/models/collavre/topic.rb +5 -0
  40. data/app/models/collavre/user.rb +4 -0
  41. data/app/services/collavre/agent_session_abort.rb +28 -0
  42. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  43. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  44. data/app/services/collavre/ai_agent_service.rb +68 -49
  45. data/app/services/collavre/attachment_backfill.rb +26 -0
  46. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  47. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  48. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  49. data/app/services/collavre/creatives/index_query.rb +110 -8
  50. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  51. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  52. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  53. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  54. data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
  55. data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
  56. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  57. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  58. data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
  59. data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
  60. data/app/views/collavre/comments/_comment.html.erb +10 -1
  61. data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
  62. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  63. data/config/locales/channels.en.yml +2 -0
  64. data/config/locales/channels.ko.yml +2 -0
  65. data/config/locales/claude_channel.en.yml +16 -0
  66. data/config/locales/claude_channel.ko.yml +16 -0
  67. data/config/locales/comments.en.yml +3 -0
  68. data/config/locales/comments.ko.yml +3 -0
  69. data/config/locales/creatives.en.yml +2 -0
  70. data/config/locales/creatives.ko.yml +2 -0
  71. data/config/locales/integrations.en.yml +13 -2
  72. data/config/locales/integrations.ko.yml +13 -2
  73. data/config/routes.rb +12 -0
  74. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  75. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  76. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  77. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  78. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  79. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  80. data/lib/collavre/engine.rb +0 -1
  81. data/lib/collavre/integration_settings/key_definition.rb +6 -0
  82. data/lib/collavre/integration_settings/registry.rb +7 -2
  83. data/lib/collavre/version.rb +1 -1
  84. metadata +31 -2
  85. 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.purge
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) || editable_creative_reference?(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
- collection.map { |c| { id: c.id, description: c.effective_description(nil, false), progress: c.progress } }
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
- abort_openclaw_session(task)
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 abort_openclaw_session(task)
27
- Collavre::OpenclawAbortService.call(agent: task.agent, task: task)
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
- error_key = case status
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
- const collectedStyles = collectDomTextStyles(container)
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
- uniqueNodes.forEach((node) => {
196
- if ($isTextNode(node)) {
197
- const paragraph = $createParagraphNode()
198
- paragraph.append(node)
199
- root.append(paragraph)
200
- appendedNodes.push(paragraph)
201
- return
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
- if ($isElementNode(node) && node.getType?.() === "paragraph") {
205
- root.append(node)
206
- appendedNodes.push(node)
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
- }, [collectDomTextStyles, editor, html])
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
- serialized = doc.body.innerHTML
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,