collavre 0.22.0 → 0.23.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/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
module Mobile
|
|
7
|
+
# Shared base for the voice-companion mobile API. Inherits Doorkeeper
|
|
8
|
+
# bearer auth from Api::V1::BaseController.
|
|
9
|
+
#
|
|
10
|
+
# ★ The API base controller authenticates the token but does NOT run the
|
|
11
|
+
# host app's locale around_action, so I18n would otherwise serve the
|
|
12
|
+
# process-default locale. Every persisted/spoken string here must match
|
|
13
|
+
# the speaker, so we wrap every action in I18n.with_locale (app-supplied
|
|
14
|
+
# `locale` param wins, else the user's stored locale).
|
|
15
|
+
class BaseController < Collavre::Api::V1::BaseController
|
|
16
|
+
around_action :with_request_locale
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_request_locale(&block)
|
|
21
|
+
I18n.with_locale(requested_locale, &block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def requested_locale
|
|
25
|
+
normalize_locale(params[:locale].presence || current_user&.locale)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def normalize_locale(value)
|
|
29
|
+
return I18n.default_locale if value.blank?
|
|
30
|
+
|
|
31
|
+
base = value.to_s.tr("-", "_").split("_").first.to_sym
|
|
32
|
+
I18n.available_locales.include?(base) ? base : I18n.default_locale
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def summarizer
|
|
36
|
+
@summarizer ||= Collavre::Mobile::EventSummarizer.new(locale: I18n.locale)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# User-owned AI agents (Claude Channel sessions, etc.).
|
|
40
|
+
def agent_ids
|
|
41
|
+
@agent_ids ||= Collavre::User.ai_agents.where(created_by_id: current_user.id).pluck(:id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Undecided permission prompts the token holder is the approver for.
|
|
45
|
+
def pending_approvals
|
|
46
|
+
Collavre::Comment.where(approver_id: current_user.id, action_executed_at: nil)
|
|
47
|
+
.where.not(action: nil)
|
|
48
|
+
.order(:created_at)
|
|
49
|
+
.limit(50)
|
|
50
|
+
.select(&:claude_channel_permission?)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reply(key)
|
|
54
|
+
I18n.t("collavre.mobile.reply.#{key}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def label_for_topic(topic_id)
|
|
58
|
+
topic = topic_id && Collavre::Topic.find_by(id: topic_id)
|
|
59
|
+
topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List/display title for an event: "Creative#Topic" so the app can show
|
|
63
|
+
# which thread a message belongs to (spec: 제목을 크리에이티브#토픽 형태로).
|
|
64
|
+
def title_for_topic(topic_id)
|
|
65
|
+
topic = topic_id && Collavre::Topic.find_by(id: topic_id)
|
|
66
|
+
name = topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
|
|
67
|
+
creative = topic&.creative&.effective_origin || topic&.creative
|
|
68
|
+
creative&.description.present? ? "#{creative.description}##{name}" : name
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render_speak(reply_key_or_text, action: {}, speak: true, status: :ok)
|
|
72
|
+
text = reply_key_or_text.is_a?(Symbol) ? reply(reply_key_or_text) : reply_key_or_text
|
|
73
|
+
render json: { reply: text, speak: speak, action: action }, status: status
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Gate + decide + broadcast a Claude Channel permission. Routes the
|
|
77
|
+
# voice path through the same approval_status gate the web path uses
|
|
78
|
+
# (Comments::ApprovalActions). Without this the caller could decide a
|
|
79
|
+
# prompt they merely own the authoring agent for but are not the
|
|
80
|
+
# designated approver of (authorized_comment? lets agent-owned events
|
|
81
|
+
# through). Returns a symbol: :ok | :unauthorized | :already_decided.
|
|
82
|
+
def decide_permission(comment, behavior)
|
|
83
|
+
return :unauthorized unless comment.can_be_approved_by?(current_user)
|
|
84
|
+
|
|
85
|
+
comment.decide_claude_channel_permission!(behavior, by: current_user)
|
|
86
|
+
comment.broadcast_claude_channel_permission_decision(behavior)
|
|
87
|
+
:ok
|
|
88
|
+
rescue Collavre::Comment::ClaudeChannelPermission::AlreadyDecided
|
|
89
|
+
:already_decided
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
module Mobile
|
|
7
|
+
# FCM registration for the Android companion. Push is a follow-up (MVP
|
|
8
|
+
# uses foreground polling), but registering the device now keeps the
|
|
9
|
+
# token current so push can light up without a client change.
|
|
10
|
+
class DevicesController < BaseController
|
|
11
|
+
def create
|
|
12
|
+
device = Collavre::Device.find_by(fcm_token: device_params[:fcm_token]) ||
|
|
13
|
+
current_user.devices.find_or_initialize_by(client_id: device_params[:client_id])
|
|
14
|
+
|
|
15
|
+
device.assign_attributes(device_params)
|
|
16
|
+
device.user = current_user
|
|
17
|
+
device.device_type = :android if device.device_type.blank?
|
|
18
|
+
device.save!
|
|
19
|
+
head :no_content
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def device_params
|
|
25
|
+
params.require(:device).permit(:client_id, :device_type, :app_id, :app_version, :fcm_token)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
module Mobile
|
|
7
|
+
# A plain mic press (no message selected) routes here: the utterance
|
|
8
|
+
# starts a new piece of work in the user's Inbox#Main, exactly like
|
|
9
|
+
# typing in the inbox chat — dispatch then matches an agent via
|
|
10
|
+
# routing_expression. Replies to a SPECIFIC message go through
|
|
11
|
+
# agent_events#respond instead (they carry the target event id).
|
|
12
|
+
class VoiceCommandsController < BaseController
|
|
13
|
+
def create
|
|
14
|
+
text = params[:text].to_s.strip
|
|
15
|
+
return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
|
|
16
|
+
|
|
17
|
+
inbox = Collavre::Creative.inbox_for(current_user)
|
|
18
|
+
comment = inbox.comments.create!(content: text, user: current_user)
|
|
19
|
+
render_speak(:sent, action: { type: "message_sent", comment_id: comment.id })
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -67,7 +67,9 @@ module Collavre
|
|
|
67
67
|
allowed_creative_ids: @allowed_creative_ids,
|
|
68
68
|
progress_map: @progress_map
|
|
69
69
|
)
|
|
70
|
-
|
|
70
|
+
payload = { creatives: @creatives_tree_json }
|
|
71
|
+
payload[:pagination] = index_result.pagination if index_result.pagination
|
|
72
|
+
render json: payload
|
|
71
73
|
end
|
|
72
74
|
end
|
|
73
75
|
end
|
|
@@ -155,6 +157,10 @@ module Collavre
|
|
|
155
157
|
render json: {
|
|
156
158
|
id: @creative.id,
|
|
157
159
|
description: @creative.effective_description,
|
|
160
|
+
# Embedded variant for read-only display (e.g. slide view): turns
|
|
161
|
+
# bare YouTube links into preview iframes. `description` stays the
|
|
162
|
+
# raw editable form the inline editor round-trips.
|
|
163
|
+
description_embedded_html: view_context.embed_youtube_iframe(@creative.effective_description),
|
|
158
164
|
description_raw_html: @creative.description,
|
|
159
165
|
origin_id: @creative.origin_id,
|
|
160
166
|
parent_id: @creative.parent_id,
|
|
@@ -165,6 +171,7 @@ module Collavre
|
|
|
165
171
|
has_children: children_count > 0,
|
|
166
172
|
data: sanitized_data,
|
|
167
173
|
content_type: effective.data&.dig("content_type"),
|
|
174
|
+
markdown_editor: effective.data&.dig("editor"),
|
|
168
175
|
markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
|
|
169
176
|
trigger_loop: trigger_loop_data,
|
|
170
177
|
is_trigger_task: parent_trigger_enabled,
|
|
@@ -210,6 +217,7 @@ module Collavre
|
|
|
210
217
|
render json: {
|
|
211
218
|
id: @creative.id,
|
|
212
219
|
content_type: @creative.data&.dig("content_type"),
|
|
220
|
+
markdown_editor: @creative.data&.dig("editor"),
|
|
213
221
|
markdown_source: @creative.data&.dig("markdown_source")
|
|
214
222
|
}
|
|
215
223
|
else
|
|
@@ -281,7 +289,8 @@ module Collavre
|
|
|
281
289
|
progress: base.progress,
|
|
282
290
|
progress_html: view_context.render_creative_progress(base),
|
|
283
291
|
has_children: base.children.exists?,
|
|
284
|
-
content_type: base.data&.dig("content_type")
|
|
292
|
+
content_type: base.data&.dig("content_type"),
|
|
293
|
+
markdown_editor: base.data&.dig("editor")
|
|
285
294
|
}
|
|
286
295
|
# Expose the post-rewrite markdown source so the client can sync its
|
|
287
296
|
# textarea after the server replaces inline data: URIs with blob paths.
|
|
@@ -394,9 +403,11 @@ module Collavre
|
|
|
394
403
|
return
|
|
395
404
|
end
|
|
396
405
|
# Reserved markdown fields are not editable via metadata; preserve current values so a stale
|
|
397
|
-
# YAML payload from the metadata popup can't overwrite a
|
|
406
|
+
# YAML payload from the metadata popup (or an API client that omits them) can't overwrite a
|
|
407
|
+
# concurrent markdown edit. "editor" must be reserved too: dropping it would erase the "rich"
|
|
408
|
+
# authoring flag and make a Lexical-authored creative reopen in the advanced textarea.
|
|
398
409
|
current_data = creative.data || {}
|
|
399
|
-
%w[markdown_source content_type].each do |key|
|
|
410
|
+
%w[markdown_source content_type editor].each do |key|
|
|
400
411
|
if current_data.key?(key)
|
|
401
412
|
new_data[key] = current_data[key]
|
|
402
413
|
else
|
|
@@ -543,7 +554,7 @@ module Collavre
|
|
|
543
554
|
end
|
|
544
555
|
|
|
545
556
|
def creative_params
|
|
546
|
-
params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
|
|
557
|
+
params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input, :markdown_editor)
|
|
547
558
|
end
|
|
548
559
|
|
|
549
560
|
def any_filter_active?
|
|
@@ -14,12 +14,21 @@ module Collavre
|
|
|
14
14
|
return head :unprocessable_entity
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# These statuses count against the topic slot (occupying_topic_slot) yet no
|
|
18
|
+
# live worker will run AiAgentJob's ensure-block drain for them:
|
|
19
|
+
# - delegated / pending_approval already returned from the job holding the
|
|
20
|
+
# slot (should_release = false) — awaiting an MCP reply / approval.
|
|
21
|
+
# - pending may be a waiter that dequeue_next_for_topic promoted
|
|
22
|
+
# queued -> pending before its job starts; once cancelled, that job
|
|
23
|
+
# early-returns at the top of #perform and never reaches the ensure drain.
|
|
24
|
+
# So free the agent slot and drain the topic queue here — otherwise
|
|
25
|
+
# cancelling the blocker leaves agent capacity and the next waiter stuck
|
|
26
|
+
# until stuck recovery. release!/dequeue are idempotent (dequeue is bounded
|
|
27
|
+
# by topic_at_capacity?), so a racing live worker that also drains is harmless.
|
|
28
|
+
held_slot_without_worker = %w[pending delegated pending_approval].include?(task.status)
|
|
18
29
|
task.update!(status: "cancelled")
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
# so free the agent slot and drain the topic queue here.
|
|
22
|
-
if was_delegated && task.agent
|
|
31
|
+
if held_slot_without_worker && task.agent
|
|
23
32
|
Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
|
|
24
33
|
Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
25
34
|
end
|
|
@@ -128,6 +128,7 @@ module Collavre
|
|
|
128
128
|
|
|
129
129
|
def move
|
|
130
130
|
topic = @creative.topics.find(params[:id])
|
|
131
|
+
source_creative = @creative
|
|
131
132
|
target_creative = Creative.find(params[:target_creative_id]).effective_origin
|
|
132
133
|
|
|
133
134
|
unless target_creative.has_permission?(Current.user, :write) || target_creative.user == Current.user
|
|
@@ -147,7 +148,13 @@ module Collavre
|
|
|
147
148
|
broadcast_topic_event("deleted", topic_id: topic.id)
|
|
148
149
|
broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
|
|
149
150
|
|
|
150
|
-
render json: {
|
|
151
|
+
render json: {
|
|
152
|
+
success: true,
|
|
153
|
+
topic: topic.slice(:id, :name),
|
|
154
|
+
target_creative_id: target_creative.id,
|
|
155
|
+
target_creative_name: target_creative.creative_snippet,
|
|
156
|
+
missing_members: missing_members_for_move(source_creative, target_creative)
|
|
157
|
+
}
|
|
151
158
|
end
|
|
152
159
|
|
|
153
160
|
def archive
|
|
@@ -204,6 +211,47 @@ module Collavre
|
|
|
204
211
|
@creative = Creative.find(params[:creative_id]).effective_origin
|
|
205
212
|
end
|
|
206
213
|
|
|
214
|
+
# When a topic moves to a different creative, members who had access on the
|
|
215
|
+
# source creative may lose visibility on the target. Returns the human
|
|
216
|
+
# members who are effectively present on the source (owner + feedback-or-
|
|
217
|
+
# higher shares) but have no resolvable access on the target, so the client
|
|
218
|
+
# can offer to re-add them with the same permission. Only returned when the
|
|
219
|
+
# current user can actually grant shares on the target (admin/owner);
|
|
220
|
+
# otherwise the suggestion would be unusable.
|
|
221
|
+
def missing_members_for_move(source_creative, target_creative)
|
|
222
|
+
return [] unless target_creative.has_permission?(Current.user, :admin) || target_creative.user == Current.user
|
|
223
|
+
|
|
224
|
+
# Any user with a resolvable share on the target chain (including inherited
|
|
225
|
+
# shares and explicit no_access blocks) already has — or is intentionally
|
|
226
|
+
# denied — access, so they are never "missing".
|
|
227
|
+
excluded_user_ids = target_creative.all_shared_users(:no_access).map(&:user_id).compact.to_set
|
|
228
|
+
excluded_user_ids << target_creative.user_id
|
|
229
|
+
excluded_user_ids << Current.user&.id
|
|
230
|
+
|
|
231
|
+
# Candidate => grant permission. Source owner had full control, so we
|
|
232
|
+
# suggest admin; shared members keep their own (closest) permission.
|
|
233
|
+
candidates = {}
|
|
234
|
+
owner = source_creative.user
|
|
235
|
+
if owner && !owner.ai_user? && excluded_user_ids.exclude?(owner.id)
|
|
236
|
+
candidates[owner.id] = { user: owner, permission: "admin" }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
source_creative.all_shared_users(:feedback).each do |share|
|
|
240
|
+
user = share.user
|
|
241
|
+
next if user.nil? || user.ai_user?
|
|
242
|
+
next if excluded_user_ids.include?(user.id)
|
|
243
|
+
|
|
244
|
+
candidates[user.id] ||= { user: user, permission: share.permission }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
candidates.values.map do |candidate|
|
|
248
|
+
{
|
|
249
|
+
user: view_context.user_json(candidate[:user], email: true),
|
|
250
|
+
permission: candidate[:permission]
|
|
251
|
+
}
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
207
255
|
def topic_params
|
|
208
256
|
params.require(:topic).permit(:name)
|
|
209
257
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Session-authenticated endpoint backing the inline typo-correction UI. The
|
|
3
|
+
# frontend also gates by device/location, but we re-check here (fail closed)
|
|
4
|
+
# before spending an LLM call, and echo the user's auto-apply threshold so the
|
|
5
|
+
# client never has to hardcode it.
|
|
6
|
+
class TypoCorrectionsController < ApplicationController
|
|
7
|
+
# The client debounce is only a hint — a scripted/compromised page can loop
|
|
8
|
+
# this endpoint with arbitrary text, and every accepted call spends an LLM
|
|
9
|
+
# request. Bound both the call rate and the per-call size server-side. The
|
|
10
|
+
# chat composer is short text; anything larger is not a typo-correction case.
|
|
11
|
+
MAX_TEXT_LENGTH = 4_000
|
|
12
|
+
RATE_LIMIT = 120 # requests
|
|
13
|
+
RATE_PERIOD = 1.minute # ...per user per minute
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
user = Current.user
|
|
17
|
+
device = params[:device].to_s
|
|
18
|
+
location = params[:location].presence || "chat"
|
|
19
|
+
text = params[:text].to_s
|
|
20
|
+
|
|
21
|
+
unless user.typo_correction_active_for?(device: device, location: location)
|
|
22
|
+
return render json: { edits: [], threshold: user.typo_correction_threshold }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Oversized input: skip the LLM entirely (return no edits, not an error —
|
|
26
|
+
# this is a background affordance, not a user-facing failure).
|
|
27
|
+
if text.length > MAX_TEXT_LENGTH
|
|
28
|
+
return render json: { edits: [], threshold: user.typo_correction_threshold }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Raises RateLimitExceeded -> 429 (DynamicRateLimiting), which the client
|
|
32
|
+
# treats as "no suggestions this round".
|
|
33
|
+
check_rate_limit!(key: "typo_correction:#{user.id}", limit: RATE_LIMIT, period: RATE_PERIOD)
|
|
34
|
+
|
|
35
|
+
edits = TypoCorrector.new.correct(text)
|
|
36
|
+
render json: { edits: edits, threshold: user.typo_correction_threshold }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -58,6 +58,14 @@ module Collavre
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
def typo_correction
|
|
62
|
+
@user = Collavre::User.find(params[:id])
|
|
63
|
+
|
|
64
|
+
unless @user == Current.user || Current.user.system_admin?
|
|
65
|
+
redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def update_password
|
|
62
70
|
@user = Collavre::User.find(params[:id])
|
|
63
71
|
if @user.authenticate(params[:user][:current_password])
|
|
@@ -89,7 +97,14 @@ module Collavre
|
|
|
89
97
|
:notifications_enabled,
|
|
90
98
|
:calendar_id,
|
|
91
99
|
:timezone,
|
|
92
|
-
:locale
|
|
100
|
+
:locale,
|
|
101
|
+
:typo_correction_enabled,
|
|
102
|
+
:typo_correction_threshold,
|
|
103
|
+
:typo_correction_on_soft_keyboard,
|
|
104
|
+
:typo_correction_on_voice,
|
|
105
|
+
:typo_correction_on_physical_keyboard,
|
|
106
|
+
:typo_correction_in_chat,
|
|
107
|
+
:typo_correction_in_editor
|
|
93
108
|
).tap do |p|
|
|
94
109
|
p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
|
|
95
110
|
end
|
|
@@ -37,7 +37,34 @@ module Collavre
|
|
|
37
37
|
Collavre::Contact.ensure(user: invitation.inviter, contact_user: @user)
|
|
38
38
|
invitation.creative.create_linked_creative_for_user(@user)
|
|
39
39
|
end
|
|
40
|
-
|
|
40
|
+
# Deployments with no outbound mail (e.g. the local desktop app, which
|
|
41
|
+
# has no SMTP/SES) would otherwise leave the first user unable to verify
|
|
42
|
+
# and permanently locked out of login. Such envs opt into auto-verify.
|
|
43
|
+
# NB: compare to `true` explicitly — an unset config.x.* key reads back
|
|
44
|
+
# as a truthy empty OrderedOptions, so a bare `if` would fire everywhere.
|
|
45
|
+
if Rails.application.config.x.auto_verify_email == true
|
|
46
|
+
@user.update_column(:email_verified_at, Time.current)
|
|
47
|
+
else
|
|
48
|
+
Collavre::EmailVerificationMailer.verify(@user).deliver_later
|
|
49
|
+
end
|
|
50
|
+
# Envs that ship no seeded admin (e.g. desktop) bootstrap one here: the
|
|
51
|
+
# first registered user becomes system_admin. Guarded by "no admin yet"
|
|
52
|
+
# so only the very first signup is elevated, and by a loopback origin so
|
|
53
|
+
# that in documented open mode (LAN/Tailscale binding) a remote client
|
|
54
|
+
# cannot race the local owner for the admin account. `== true` for the
|
|
55
|
+
# same OrderedOptions reason as above.
|
|
56
|
+
if Rails.application.config.x.bootstrap_first_admin == true && request_from_loopback?
|
|
57
|
+
# Promote atomically rather than exists?-then-update: a separate check
|
|
58
|
+
# and write is a TOCTOU race where two concurrent first signups both
|
|
59
|
+
# read "no admin yet" and both elevate. A single UPDATE ... WHERE NOT
|
|
60
|
+
# EXISTS lets the DB evaluate the guard and write under one lock, so
|
|
61
|
+
# exactly one wins. This path only runs on desktop (the only env that
|
|
62
|
+
# sets bootstrap_first_admin), which is always SQLite — and SQLite
|
|
63
|
+
# serializes writers, so the loser's re-evaluated NOT EXISTS sees the
|
|
64
|
+
# admin and updates nothing.
|
|
65
|
+
admin_exists = Collavre::User.where(system_admin: true).arel.exists
|
|
66
|
+
Collavre::User.where(id: @user.id).where(admin_exists.not).update_all(system_admin: true)
|
|
67
|
+
end
|
|
41
68
|
session.delete(:return_to_after_authenticating)
|
|
42
69
|
redirect_to new_session_path, notice: I18n.t("collavre.users.new.success_sign_up")
|
|
43
70
|
else
|
|
@@ -56,6 +83,19 @@ module Collavre
|
|
|
56
83
|
|
|
57
84
|
private
|
|
58
85
|
|
|
86
|
+
# Whether the request originates from the local machine. Uses remote_addr
|
|
87
|
+
# (the socket peer) rather than remote_ip: remote_ip trusts X-Forwarded-For
|
|
88
|
+
# from private-range proxies, so a LAN attacker at e.g. 192.168.x could spoof
|
|
89
|
+
# `XFF: 127.0.0.1` and forge a loopback origin. The socket peer cannot be
|
|
90
|
+
# spoofed by headers.
|
|
91
|
+
def request_from_loopback?
|
|
92
|
+
addr = IPAddr.new(request.remote_addr.to_s)
|
|
93
|
+
addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
|
|
94
|
+
addr.loopback?
|
|
95
|
+
rescue IPAddr::InvalidAddressError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
59
99
|
def user_params
|
|
60
100
|
params.require(:user).permit(:email, :password, :password_confirmation, :name)
|
|
61
101
|
end
|
data/app/javascript/collavre.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import "./modules/creatives"
|
|
6
6
|
import "./modules/creative_row_swipe"
|
|
7
7
|
import "./modules/mention_menu"
|
|
8
|
+
import "./modules/typo_corrector"
|
|
8
9
|
import "./modules/command_menu"
|
|
9
10
|
import "./modules/modal_dialog"
|
|
10
11
|
import "./modules/export_to_markdown"
|
|
@@ -19,6 +20,7 @@ import "./components/creative_tree_row"
|
|
|
19
20
|
// Import and re-export lib utilities
|
|
20
21
|
import "./lib/apply_lexical_styles"
|
|
21
22
|
import "./lib/turbo_stream_actions"
|
|
23
|
+
import "./lib/turbo_confirm"
|
|
22
24
|
|
|
23
25
|
// Export controller registration
|
|
24
26
|
export { registerControllers } from "./controllers"
|
|
@@ -51,9 +51,15 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
|
|
|
51
51
|
editor.registerCommand(
|
|
52
52
|
CLICK_COMMAND,
|
|
53
53
|
(event) => {
|
|
54
|
-
// If click is outside our image, deselect
|
|
54
|
+
// If click is outside our image, deselect — but only via clearSelection.
|
|
55
|
+
// setSelected(false) is destructive here: when the click lands in text
|
|
56
|
+
// (a RangeSelection / the caret), the hook synthesizes a fresh empty
|
|
57
|
+
// NodeSelection and replaces the caret with it, so the editor loses its
|
|
58
|
+
// caret and becomes uneditable the instant you click to edit while an
|
|
59
|
+
// image is present. clearSelection only acts on an existing
|
|
60
|
+
// NodeSelection, so a RangeSelection (caret) is preserved.
|
|
55
61
|
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
56
|
-
|
|
62
|
+
clearSelection()
|
|
57
63
|
}
|
|
58
64
|
return false
|
|
59
65
|
},
|
|
@@ -90,7 +96,7 @@ export default function ImageResizer({ src, altText, width, height, nodeKey }) {
|
|
|
90
96
|
COMMAND_PRIORITY_LOW
|
|
91
97
|
)
|
|
92
98
|
)
|
|
93
|
-
}, [editor, isSelected, nodeKey,
|
|
99
|
+
}, [editor, isSelected, nodeKey, clearSelection])
|
|
94
100
|
|
|
95
101
|
// Resize logic
|
|
96
102
|
const onPointerDown = useCallback(
|