collavre 0.23.0 → 0.24.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -134,45 +134,21 @@ module Collavre
134
134
  result
135
135
  end
136
136
 
137
- # Mirrors the access check in Creative#children_with_permission, but
138
- # operates on a pre-fetched set of candidate children so the work
139
- # collapses to a fixed number of queries instead of one per parent.
137
+ # Mirrors the access check in Creative#children_with_permission via the
138
+ # single PermissionFilter read path: the deny-invariant (owner wins, user
139
+ # entry incl. no_access beats public) is served by #readable_ids, and the
140
+ # "a user always sees their own children" listing policy is preserved as an
141
+ # explicit union — identical to children_with_permission's shape, including
142
+ # the owned-shell-with-unreadable-origin case readable_ids gates out.
140
143
  def accessible_child_ids(candidate_ids, candidate_children, user)
141
144
  return Set.new if candidate_ids.empty?
142
145
 
143
- min_rank = CreativeShare.permissions["read"]
144
- accessible = Set.new
146
+ filter = Collavre::Creatives::PermissionFilter.new(user: user)
147
+ accessible = filter.readable_ids(candidate_ids, min_permission: :read).to_set
145
148
 
146
149
  if user
147
- user_entries = CreativeSharesCache
148
- .where(creative_id: candidate_ids, user_id: user.id)
149
- .pluck(:creative_id, :permission)
150
-
151
- user_has_entry = Set.new
152
- user_entries.each do |cid, perm|
153
- user_has_entry << cid
154
- perm_rank = CreativeSharesCache.permissions[perm]
155
- if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
156
- accessible << cid
157
- end
158
- end
159
-
160
- public_accessible = CreativeSharesCache
161
- .where(creative_id: candidate_ids, user_id: nil)
162
- .where("permission >= ?", min_rank)
163
- .where.not(permission: :no_access)
164
- .pluck(:creative_id)
165
- accessible.merge(public_accessible.reject { |cid| user_has_entry.include?(cid) })
166
-
167
150
  owned_ids = candidate_children.select { |c| c.user_id == user.id }.map(&:id)
168
151
  accessible.merge(owned_ids)
169
- else
170
- public_accessible = CreativeSharesCache
171
- .where(creative_id: candidate_ids, user_id: nil)
172
- .where("permission >= ?", min_rank)
173
- .where.not(permission: :no_access)
174
- .pluck(:creative_id)
175
- accessible.merge(public_accessible)
176
152
  end
177
153
 
178
154
  accessible
@@ -6,8 +6,15 @@ module Collavre
6
6
  include Collavre::Concerns::Shareable
7
7
  include Collavre::CreativePermissionGuard
8
8
 
9
- # TODO: for not for security reasons for this Collavre app, we don't expose to public, later it should be controlled by roles for each Creatives
10
- # Removed unauthenticated access to index and show actions
9
+ # Authorization for these read actions is not open-to-public: each action
10
+ # enforces per-Creative read access via has_permission?(Current.user, :read)
11
+ # (index/children go through Creatives::IndexQuery, which permission-filters;
12
+ # show/slide_view/export_markdown check has_permission? directly). Anonymous
13
+ # requests only ever see Creatives whose share grants public read. Requiring
14
+ # a login for all reads is gated by SystemSetting.creatives_login_required?
15
+ # via enforce_creatives_login_policy below. A broader per-Creative role model
16
+ # (beyond the read/feedback/write/admin share levels) is a product decision
17
+ # tracked separately and intentionally deferred.
11
18
  allow_unauthenticated_access only: %i[ index children export_markdown show slide_view ]
12
19
  before_action :enforce_creatives_login_policy, only: %i[ index children export_markdown show slide_view ]
13
20
  before_action :set_creative, only: %i[ show edit update destroy parent_suggestions slide_view request_permission unconvert contexts update_contexts update_metadata archive unarchive trigger_action ]
@@ -39,7 +46,7 @@ module Collavre
39
46
  else
40
47
  {}
41
48
  end
42
- index_result = ::Creatives::IndexQuery.new(user: Current.user, params: params.to_unsafe_h).call
49
+ index_result = ::Creatives::IndexQuery.new(user: Current.user, params: index_query_params).call
43
50
  @creatives = index_result.creatives || []
44
51
  @parent_creative = index_result.parent_creative
45
52
  @shared_creative = index_result.shared_creative
@@ -407,7 +414,7 @@ module Collavre
407
414
  # concurrent markdown edit. "editor" must be reserved too: dropping it would erase the "rich"
408
415
  # authoring flag and make a Lexical-authored creative reopen in the advanced textarea.
409
416
  current_data = creative.data || {}
410
- %w[markdown_source content_type editor].each do |key|
417
+ Collavre::Creative.reserved_metadata_keys.each do |key|
411
418
  if current_data.key?(key)
412
419
  new_data[key] = current_data[key]
413
420
  else
@@ -557,6 +564,19 @@ module Collavre
557
564
  params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input, :markdown_editor)
558
565
  end
559
566
 
567
+ # Whitelist of query parameters consumed by Creatives::IndexQuery and its
568
+ # FilterPipeline. Passing an explicitly permitted hash (rather than
569
+ # params.to_unsafe_h) keeps arbitrary client-supplied keys out of the
570
+ # query layer while preserving every filter the index endpoint supports.
571
+ def index_query_params
572
+ params.permit(
573
+ :id, :simple, :search, :search_mode, :comment, :has_comments,
574
+ :min_progress, :max_progress, :due_before, :due_after, :has_due_date,
575
+ :assignee_id, :unassigned, :show_archived, :page, :per_page,
576
+ tags: []
577
+ ).to_h
578
+ end
579
+
560
580
  def any_filter_active?
561
581
  params[:tags].present? ||
562
582
  params[:min_progress].present? ||
@@ -572,102 +592,15 @@ module Collavre
572
592
  params[:show_archived].present?
573
593
  end
574
594
 
595
+ # Thin delegator to Creatives::CreativeTreeSerializer. Kept as a controller
596
+ # method (rather than inlining the service call at the index call site) so
597
+ # the picker test that drives serialization via controller.send(:serialize_creatives, ...)
598
+ # keeps exercising the same seam.
575
599
  def serialize_creatives(collection)
576
- if params[:simple].present?
577
- # Preload origins so effective_origin (used per linked-shell row in both
578
- # children_presence_set and the origin_id mapping below) resolves from
579
- # memory instead of firing a query per shell. Only browse can hold shells;
580
- # search is scoped to origin_id: nil, so this is a no-op there.
581
- ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
582
- children_ids = children_presence_set(collection)
583
- searching = params[:search].present?
584
- breadcrumbs = searching ? ::Creatives::BreadcrumbResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
585
- # For hits routed through a linked shell, the path to expand in the
586
- # user's own tree (local folders -> shell) so a breadcrumb jump can
587
- # reach a shell nested under a collapsed folder.
588
- reveal_paths = searching ? ::Creatives::RevealPathResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
589
- collection.map do |c|
590
- item = {
591
- id: c.id,
592
- description: c.effective_description(nil, false),
593
- progress: c.progress,
594
- has_children: children_ids.include?(c.id)
595
- }
596
- reveal = reveal_paths[c.id]
597
- path = breadcrumbs[c.id]
598
- if path.present?
599
- item[:path] = mask_unreachable_crumbs(path, reveal)
600
- end
601
- item[:reveal_path] = reveal if reveal.present?
602
- # For linked shells, expose the effective origin id so the picker can
603
- # map a search breadcrumb (origin ids) back to the rendered shell node.
604
- item[:origin_id] = c.effective_origin.id if c.origin_id
605
- item
606
- end
607
- else
608
- collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
609
- end
610
- end
611
-
612
- # A breadcrumb jump expands the tree from a rendered root (or, for a shared
613
- # subtree, a linked shell) down to the clicked crumb. If an ancestor above a
614
- # crumb is itself unrenderable (unreadable, or archived while archived rows
615
- # aren't shown) and no linked shell re-roots the path at/below it, the
616
- # descendant can't be expanded either — so mask it too. BreadcrumbResolver
617
- # masks the unrenderable ancestor itself; this masks everything downstream of
618
- # it on the plain origin chain, matching exactly what the tree can render.
619
- #
620
- # `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
621
- # a key re-roots navigation through its own shell (the client anchors at the
622
- # nearest reveal entry at/above the clicked crumb), so it clears the block for
623
- # itself and its descendants regardless of an archived/unreadable origin
624
- # above it.
625
- def mask_unreachable_crumbs(path, reveal)
626
- blocked = false
627
- path.map do |crumb|
628
- if reveal&.key?(crumb[:id])
629
- blocked = false
630
- crumb
631
- elsif crumb[:restricted]
632
- blocked = true
633
- crumb
634
- elsif blocked
635
- crumb.merge(restricted: true, description: nil)
636
- else
637
- crumb
638
- end
639
- end
640
- end
641
-
642
- # Batched "does this node have a child the user can actually browse to?"
643
- # lookup so the picker tree renders expand toggles without an N+1.
644
- #
645
- # Must match exactly what expanding the node shows (IndexQuery#handle_id_query
646
- # -> children_with_permission, minus archived unless show_archived), or the
647
- # toggle either hides a reachable subtree or opens to an empty branch (and
648
- # leaks that hidden children exist). Two alignments are needed:
649
- # 1. Linked shells (origin_id set) store children under the effective
650
- # origin (redirect_parent_to_origin + children->origin migration), so
651
- # resolve each row to its effective origin before the lookup.
652
- # 2. Apply the same archived + read-permission filters as the browse path.
653
- def children_presence_set(collection)
654
- return Set.new if collection.empty?
655
-
656
- origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
657
-
658
- candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
659
- candidates = candidates.where(archived_at: nil) unless params[:show_archived]
660
- child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
661
- return Set.new if child_rows.empty?
662
-
663
- readable = ::Creatives::PermissionFilter
664
- .new(user: Current.user).readable_ids(child_rows.map(&:first)).to_set
665
- origins_with_visible_children = child_rows
666
- .each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
667
-
668
- collection.each_with_object(Set.new) do |c, set|
669
- set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
670
- end
600
+ # Fully-qualified name: this newly extracted service is not registered in
601
+ # config/initializers/collavre_model_aliases.rb, so the top-level
602
+ # ::Creatives::* alias used by the older sibling services is unavailable.
603
+ Collavre::Creatives::CreativeTreeSerializer.new(user: Current.user, params: params).serialize(collection)
671
604
  end
672
605
 
673
606
  def reorderer
@@ -38,12 +38,18 @@ module Collavre
38
38
  end
39
39
 
40
40
  def update
41
- if @inbox_item.owner == Current.user
42
- @inbox_item.update(state: params[:state])
43
- head :no_content
44
- else
41
+ unless @inbox_item.owner == Current.user
45
42
  head :forbidden
43
+ return
46
44
  end
45
+
46
+ unless InboxItem.states.key?(params[:state].to_s)
47
+ head :unprocessable_entity
48
+ return
49
+ end
50
+
51
+ @inbox_item.update(state: params[:state])
52
+ head :no_content
47
53
  end
48
54
 
49
55
  def destroy
@@ -2,7 +2,10 @@ module Collavre
2
2
  class LandingController < ApplicationController
3
3
  allow_unauthenticated_access
4
4
 
5
- def show
6
- end
5
+ # The demo video/poster URLs live in config/locales/landing.*.yml as full
6
+ # absolute URLs pointing at the hosted assets. Nothing to resolve here — the
7
+ # view reads them directly, so the landing page has no dependency on the
8
+ # Creatives model and the URLs work from any environment (preview included).
9
+ def show; end
7
10
  end
8
11
  end
@@ -145,6 +145,11 @@ module Collavre
145
145
  topic.update!(creative: target_creative)
146
146
  end
147
147
 
148
+ # update_all above skips counter-cache callbacks, so recompute
149
+ # comments_count on both sides after re-parenting the comments.
150
+ Creative.reset_counters(source_creative.id, :comments)
151
+ Creative.reset_counters(target_creative.id, :comments)
152
+
148
153
  broadcast_topic_event("deleted", topic_id: topic.id)
149
154
  broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
150
155
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module Authenticatable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def extract_bearer_token
11
+ auth_header = request.headers["Authorization"]
12
+ return nil unless auth_header&.start_with?("Bearer ")
13
+
14
+ auth_header.sub("Bearer ", "")
15
+ end
16
+
17
+ # Resolves the bearer token to a user via Doorkeeper, sets
18
+ # Collavre::Current.user, and returns the user. Returns nil (without
19
+ # rendering) when the token is blank/invalid/inaccessible or the user
20
+ # is missing — callers own the error response, because the two API
21
+ # engines render different error envelopes.
22
+ def find_user_by_bearer_token(token)
23
+ return nil if token.blank?
24
+
25
+ access_token = Doorkeeper::AccessToken.by_token(token)
26
+ return nil unless access_token&.accessible?
27
+
28
+ user = Collavre::User.find_by(id: access_token.resource_owner_id)
29
+ return nil unless user
30
+
31
+ Collavre::Current.user = user
32
+ user
33
+ end
34
+
35
+ def current_user
36
+ Collavre::Current.user
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Raised inside collavre-core API v1 controllers to produce the canonical
5
+ # single-message JSON envelope `{ error: "<message>" }` at a chosen status,
6
+ # via the rescue_from in Collavre::Api::V1::BaseController.
7
+ #
8
+ # NOTE: this is the collavre-core `{error: "str"}` shape only. It is NOT for
9
+ # the 422 validation-array shape `{errors: [...]}` (client retry contract),
10
+ # nor the OpenAI-nested shape in collavre_completion_api. Do not route those
11
+ # through this class.
12
+ class ApiError < StandardError
13
+ attr_reader :status
14
+
15
+ def initialize(message, status: :unprocessable_entity)
16
+ super(message)
17
+ @status = status
18
+ end
19
+ end
20
+ end
@@ -18,14 +18,25 @@ module Collavre
18
18
  end
19
19
 
20
20
  def render_creative_tags(creative)
21
- labels = creative.tags&.includes(:label)&.map(&:label)&.compact
21
+ # The browse tree preloads tags -> label per level; `includes` on an
22
+ # already-loaded association builds a fresh relation and re-queries it,
23
+ # which would reinstate the N+1 this is called from.
24
+ labels = if creative.tags.loaded?
25
+ creative.tags.map(&:label).compact
26
+ else
27
+ creative.tags&.includes(:label)&.map(&:label)&.compact
28
+ end
22
29
  return "" if labels&.empty?
23
30
  content_tag(:div, class: "creative-tags", style: "display: none;") do
24
31
  render_tags(labels, "unstyled-link", true)
25
32
  end
26
33
  end
27
34
 
28
- def render_creative_progress(creative, select_mode: false, has_children: nil)
35
+ # `can_write`, `can_feedback` and `unread_count` let a caller rendering many
36
+ # creatives at once (the browse tree) resolve them in batch and hand them in.
37
+ # Left nil, each is resolved for this creative alone — correct, but a query
38
+ # per node. Single-creative call sites take that path.
39
+ def render_creative_progress(creative, select_mode: false, has_children: nil, can_write: nil, can_feedback: nil, unread_count: nil)
29
40
  progress_value = if params[:tags].present?
30
41
  tag_ids = Array(params[:tags]).map(&:to_s)
31
42
  creative.filtered_progress || creative.progress_for_tags(tag_ids) || 0
@@ -33,15 +44,15 @@ module Collavre
33
44
  creative.progress
34
45
  end
35
46
 
47
+ can_feedback = creative.has_permission?(Current.user, :feedback) if can_feedback.nil?
48
+
36
49
  content_tag(:div, class: "creative-row-end") do
37
50
  comment_part = if creative.archived?
38
51
  safe_join([])
39
- elsif creative.has_permission?(Current.user, :feedback)
52
+ elsif can_feedback
40
53
  origin = creative.effective_origin
41
- comments_count = origin.comments.size
42
- pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
43
- last_read_id = pointer&.last_read_comment_id
44
- unread_count = last_read_id ? origin.comments.where("id > ? and private = ?", last_read_id, false).count : comments_count
54
+ comments_count = origin.comments_count
55
+ unread_count = unread_count_for(origin, comments_count) if unread_count.nil?
45
56
  if Current.user && CommentPresenceStore.list(origin.id).include?(Current.user.id)
46
57
  unread_count = 0
47
58
  end
@@ -70,7 +81,7 @@ module Collavre
70
81
  safe_join([])
71
82
  end
72
83
  is_leaf = has_children.nil? ? !creative.children.exists? : !has_children
73
- can_write = creative.has_permission?(Current.user, :write)
84
+ can_write = creative.has_permission?(Current.user, :write) if can_write.nil?
74
85
  progress_part = if is_leaf && can_write && !select_mode
75
86
  render_progress_toggle(creative, progress_value)
76
87
  else
@@ -86,6 +97,17 @@ module Collavre
86
97
  end
87
98
  end
88
99
 
100
+ # Unread comments on `origin` for the current user. With no read pointer,
101
+ # every comment is unread. Batched by CommentBadgeIndex for the browse tree;
102
+ # this is the single-creative path.
103
+ def unread_count_for(origin, comments_count)
104
+ pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
105
+ last_read_id = pointer&.last_read_comment_id
106
+ return comments_count unless last_read_id
107
+
108
+ origin.comments.where("id > ? and private = ?", last_read_id, false).count
109
+ end
110
+
89
111
  def render_progress_toggle(creative, value)
90
112
  complete = value == 1
91
113
  new_value = complete ? 0 : 1
@@ -15,7 +15,6 @@ import { HeadingNode, QuoteNode, $isHeadingNode, $isQuoteNode } from "@lexical/r
15
15
  import {
16
16
  CodeNode,
17
17
  CodeHighlightNode,
18
- $createCodeNode,
19
18
  $isCodeNode,
20
19
  registerCodeHighlighting
21
20
  } from "@lexical/code"
@@ -56,6 +55,7 @@ import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
56
55
  import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
57
56
  import ListTabIndentPlugin from "./plugins/list_tab_indent_plugin"
58
57
  import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
58
+ import { $toggleCodeBlockForSelection } from "../lib/lexical/code_block_toggle"
59
59
  import { lexicalHtmlConfig, normalizeColoredContainers } from "../lib/lexical/color_import"
60
60
  import { minimizeContentHtml } from "../lib/lexical/minimize_html"
61
61
  import { ensureTrailingParagraph, registerTrailingParagraph } from "../lib/lexical/trailing_paragraph"
@@ -682,39 +682,7 @@ function Toolbar() {
682
682
 
683
683
  const toggleCodeBlock = useCallback(() => {
684
684
  editor.update(() => {
685
- const selection = $getSelection()
686
- if (!$isRangeSelection(selection)) return
687
-
688
- const anchorNode = selection.anchor.getNode()
689
- const topLevel = anchorNode.getTopLevelElement()
690
- if (!topLevel) return
691
-
692
- if ($isCodeNode(topLevel)) {
693
- const textContent = topLevel.getTextContent()
694
- const lines = textContent.split("\n")
695
- const firstParagraph = $createParagraphNode()
696
- firstParagraph.append($createTextNode(lines[0] || ""))
697
- topLevel.replace(firstParagraph)
698
- let previous = firstParagraph
699
- for (let index = 1; index < lines.length; index += 1) {
700
- const paragraph = $createParagraphNode()
701
- paragraph.append($createTextNode(lines[index]))
702
- previous.insertAfter(paragraph)
703
- previous = paragraph
704
- }
705
- firstParagraph.selectEnd()
706
- return
707
- }
708
-
709
- if (topLevel.getType?.() !== "paragraph") {
710
- return
711
- }
712
-
713
- const codeNode = $createCodeNode()
714
- const content = topLevel.getTextContent()
715
- codeNode.append($createTextNode(content || ""))
716
- topLevel.replace(codeNode)
717
- codeNode.selectEnd()
685
+ $toggleCodeBlockForSelection()
718
686
  })
719
687
  }, [editor])
720
688
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Regression: parentId single source of truth.
3
+ *
4
+ * The tree row component used to render `data-parent-id=${this.parentId ?? nothing}`,
5
+ * which OMITTED the attribute whenever parentId was null. `addNew` derives a new
6
+ * sibling's parent from `prev.dataset.parentId`, so an absent attribute produced an
7
+ * `undefined` parent downstream and lost the real parent for newly-added siblings.
8
+ *
9
+ * The single convention: `data-parent-id` is ALWAYS emitted on `.creative-tree`.
10
+ * A non-empty value is the parent id; "" means "root / no parent". This mirrors the
11
+ * dynamic-creation path (`dataset.parentId = parentId || ''`).
12
+ */
13
+
14
+ // The component calls `customElements.define(...)` at module-eval time and reads
15
+ // `customElements` as a bare global. The jest environment only exposes it on
16
+ // `window`, so bridge it before importing the component.
17
+ beforeAll(() => {
18
+ if (typeof globalThis.customElements === "undefined") {
19
+ globalThis.customElements = window.customElements;
20
+ }
21
+ });
22
+
23
+ async function mountRow(props) {
24
+ await import("../creative_tree_row.js");
25
+ const el = document.createElement("creative-tree-row");
26
+ Object.assign(el, props);
27
+ document.body.appendChild(el);
28
+ await el.updateComplete;
29
+ return el;
30
+ }
31
+
32
+ // The `.creative-tree` element is what the editor reads via `tree.dataset.parentId`.
33
+ function treeOf(el) {
34
+ return el.querySelector(".creative-tree");
35
+ }
36
+
37
+ afterEach(() => {
38
+ document.body.innerHTML = "";
39
+ });
40
+
41
+ describe("creative-tree-row parentId convention", () => {
42
+ test("non-title row under a page-title parent keeps the parent id", async () => {
43
+ const el = await mountRow({ creativeId: "8", parentId: "5", level: 2 });
44
+ const tree = treeOf(el);
45
+
46
+ // The attribute is always present and carries the real parent id — this is
47
+ // exactly what addNew reads (`prev.dataset.parentId`) when deriving a sibling.
48
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
49
+ expect(tree.dataset.parentId).toBe("5");
50
+ });
51
+
52
+ test("root row emits an explicit empty marker instead of omitting the attribute", async () => {
53
+ const el = await mountRow({ creativeId: "5", parentId: null, level: 1 });
54
+ const tree = treeOf(el);
55
+
56
+ // Before the fix this attribute was omitted (ambiguous root vs unknown).
57
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
58
+ expect(tree.dataset.parentId).toBe("");
59
+ });
60
+
61
+ test("title row preserves its own parent (nested page) instead of dropping it", async () => {
62
+ const el = await mountRow({ creativeId: "5", parentId: "2", isTitle: true });
63
+ const tree = treeOf(el);
64
+
65
+ expect(tree.classList.contains("creative-tree-title")).toBe(true);
66
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
67
+ expect(tree.dataset.parentId).toBe("2");
68
+ });
69
+
70
+ test("root title row emits explicit empty marker", async () => {
71
+ const el = await mountRow({ creativeId: "5", parentId: null, isTitle: true });
72
+ const tree = treeOf(el);
73
+
74
+ expect(tree.hasAttribute("data-parent-id")).toBe(true);
75
+ expect(tree.dataset.parentId).toBe("");
76
+ });
77
+
78
+ test("addNew sibling derivation reads the correct parent from a page-title child", async () => {
79
+ // Simulate the editor's else-branch derivation: parentId = prev.dataset.parentId
80
+ // for a child row that lives under a page-title parent (id 5).
81
+ const child = await mountRow({ creativeId: "8", parentId: "5", level: 2 });
82
+ const prev = treeOf(child);
83
+
84
+ const derivedParentId = prev.dataset.parentId || "";
85
+ expect(derivedParentId).toBe("5"); // sibling inherits the page-title parent
86
+ });
87
+ });
@@ -208,6 +208,12 @@ class CreativeTreeRow extends LitElement {
208
208
  if (this._progressToggle) this._progressToggle.addEventListener("click", this._handleProgressToggle, { passive: false });
209
209
  }
210
210
 
211
+ // parentId convention (single source of truth): `data-parent-id` is ALWAYS
212
+ // emitted on the `.creative-tree` element. A non-empty value is the parent
213
+ // creative id; an empty string ("") means "root / no parent". This mirrors
214
+ // the dynamic-creation path (`dataset.parentId = parentId || ''`) so readers
215
+ // never have to disambiguate an *absent* attribute (unknown) from a real
216
+ // root, which previously dropped the parent for newly-added siblings.
211
217
  render() {
212
218
  if (this.isTitle) {
213
219
  return this._renderTitle();
@@ -224,7 +230,7 @@ class CreativeTreeRow extends LitElement {
224
230
  class="creative-tree"
225
231
  id=${this.domId ?? nothing}
226
232
  data-id=${this.creativeId ?? nothing}
227
- data-parent-id=${this.parentId ?? nothing}
233
+ data-parent-id=${this.parentId ?? ""}
228
234
  data-level=${this.level ?? nothing}
229
235
  draggable=${draggableAttr}
230
236
  data-action=${dragActions}
@@ -250,7 +256,7 @@ class CreativeTreeRow extends LitElement {
250
256
  class="creative-tree creative-tree-title"
251
257
  id=${this.domId ?? nothing}
252
258
  data-id=${this.creativeId ?? nothing}
253
- data-parent-id=${this.parentId ?? nothing}
259
+ data-parent-id=${this.parentId ?? ""}
254
260
  >
255
261
  <div class="creative-row" style="background-color: transparent;" data-creatives--select-mode-target="row">
256
262
  <div class="creative-row-start" style="align-items: center;">
@@ -6,16 +6,19 @@ import {
6
6
  ORDERED_LIST,
7
7
  CODE
8
8
  } from "@lexical/markdown"
9
+ import { registerCodeFenceShortcut } from "../../lib/lexical/code_fence_shortcut"
9
10
 
10
11
  /**
11
12
  * Markdown-style shortcuts for the creative inline editor:
12
13
  *
13
14
  * - "* " / "- " / "+ " at line start → unordered list
14
15
  * - "1. " (any number) at line start → ordered list
15
- * - "```" + space at line start → code block
16
+ * - "```" (optionally "```lang") on its own line → code block
16
17
  *
17
- * These fire on text change (not on Enter), so they don't conflict
18
- * with the Enter→addNew() shortcut on desktop.
18
+ * The list transformers fire on text change. The built-in CODE transformer
19
+ * needs "``` " (trailing space); registerCodeFenceShortcut adds the Enter path,
20
+ * converting a "```" (or "```lang") line to a code block when Enter is pressed —
21
+ * so the language can be typed before the fence commits.
19
22
  */
20
23
  const CREATIVE_MARKDOWN_TRANSFORMERS = [
21
24
  UNORDERED_LIST,
@@ -27,7 +30,15 @@ export default function MarkdownShortcutsPlugin() {
27
30
  const [editor] = useLexicalComposerContext()
28
31
 
29
32
  useEffect(() => {
30
- return registerMarkdownShortcuts(editor, CREATIVE_MARKDOWN_TRANSFORMERS)
33
+ const unregisterShortcuts = registerMarkdownShortcuts(
34
+ editor,
35
+ CREATIVE_MARKDOWN_TRANSFORMERS
36
+ )
37
+ const unregisterFence = registerCodeFenceShortcut(editor)
38
+ return () => {
39
+ unregisterShortcuts()
40
+ unregisterFence()
41
+ }
31
42
  }, [editor])
32
43
 
33
44
  return null