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.
- checksums.yaml +4 -4
- data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- 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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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
|
-
|
|
144
|
-
accessible =
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
52
|
+
elsif can_feedback
|
|
40
53
|
origin = creative.effective_origin
|
|
41
|
-
comments_count = origin.
|
|
42
|
-
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
* - "```"
|
|
16
|
+
* - "```" (optionally "```lang") on its own line → code block
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|