collavre 0.20.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/creatives_controller.rb +50 -6
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/passwords_controller.rb +1 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/topics_controller.rb +21 -30
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/controllers/comment_controller.js +9 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +10 -7
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +83 -1
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/creative/describable.rb +65 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/topic.rb +3 -25
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +6 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +11 -0
- data/config/locales/channels.ko.yml +11 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/locales/creatives.en.yml +9 -0
- data/config/locales/creatives.ko.yml +8 -0
- data/config/locales/integrations.en.yml +44 -0
- data/config/locales/integrations.ko.yml +44 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +51 -0
- data/lib/collavre/integration_settings/key_definition.rb +29 -0
- data/lib/collavre/integration_settings/registry.rb +55 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +52 -1
|
@@ -20,13 +20,16 @@ export default class CommandArgsForm {
|
|
|
20
20
|
* @param {Element} opts.container - if set, the modal is scoped inside this element
|
|
21
21
|
* (overlay covers only the container, content is blurred)
|
|
22
22
|
* @param {Function} opts.creativeIdFn - returns current creative ID for mention search
|
|
23
|
+
* @param {Function} opts.contextValuesFn - returns { creative_id, topic_id } used to
|
|
24
|
+
* pre-fill params whose name matches
|
|
23
25
|
*/
|
|
24
|
-
constructor({ onSubmit, onCancel, labels, container, creativeIdFn } = {}) {
|
|
26
|
+
constructor({ onSubmit, onCancel, labels, container, creativeIdFn, contextValuesFn } = {}) {
|
|
25
27
|
this.onSubmit = onSubmit || (() => {})
|
|
26
28
|
this.onCancel = onCancel || (() => {})
|
|
27
29
|
this.labels = labels || { submit: 'OK', cancel: 'Cancel' }
|
|
28
30
|
this.container = container || null
|
|
29
31
|
this._creativeIdFn = creativeIdFn || (() => null)
|
|
32
|
+
this._contextValuesFn = contextValuesFn || (() => ({}))
|
|
30
33
|
this.command = null
|
|
31
34
|
this.overlay = null
|
|
32
35
|
this.dialog = null
|
|
@@ -173,6 +176,18 @@ export default class CommandArgsForm {
|
|
|
173
176
|
host.appendChild(this.dialog)
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
_contextDefaultFor(paramName) {
|
|
180
|
+
if (paramName !== 'creative_id' && paramName !== 'topic_id') return null
|
|
181
|
+
let ctx
|
|
182
|
+
try {
|
|
183
|
+
ctx = this._contextValuesFn() || {}
|
|
184
|
+
} catch (_e) {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
const value = ctx[paramName]
|
|
188
|
+
return value == null || value === '' ? null : value
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
_buildField(param, index) {
|
|
177
192
|
const wrapper = document.createElement('div')
|
|
178
193
|
wrapper.className = 'modal-dialog-field'
|
|
@@ -241,9 +256,12 @@ export default class CommandArgsForm {
|
|
|
241
256
|
input.dataset.paramRequired = param.required ? 'true' : 'false'
|
|
242
257
|
if (param.description) input.placeholder = param.description
|
|
243
258
|
|
|
244
|
-
// Pre-fill
|
|
245
|
-
|
|
246
|
-
|
|
259
|
+
// Pre-fill priority: explicit default_value (from creative tool config) wins,
|
|
260
|
+
// otherwise auto-fill creative_id / topic_id from the current chat context.
|
|
261
|
+
const contextFill = this._contextDefaultFor(param.name)
|
|
262
|
+
const prefill = param.default_value != null ? param.default_value : contextFill
|
|
263
|
+
if (prefill != null && prefill !== '') {
|
|
264
|
+
input.value = String(prefill)
|
|
247
265
|
if (input.tagName === 'TEXTAREA') {
|
|
248
266
|
requestAnimationFrame(() => this._autoResize(input))
|
|
249
267
|
}
|
|
@@ -19,6 +19,14 @@ if (!commandMenuInitialized) {
|
|
|
19
19
|
const argsForm = new CommandArgsForm({
|
|
20
20
|
container: popup,
|
|
21
21
|
creativeIdFn: () => popup.dataset.creativeId,
|
|
22
|
+
contextValuesFn: () => ({
|
|
23
|
+
// Prefer the effective origin id (what comments/topics controllers
|
|
24
|
+
// operate on). For linked creatives, popup.dataset.creativeId is the
|
|
25
|
+
// wrapper row id, but the server resolves through effective_origin —
|
|
26
|
+
// MCP tools expect the same effective id.
|
|
27
|
+
creative_id: popup.dataset.effectiveCreativeId || popup.dataset.creativeId || null,
|
|
28
|
+
topic_id: currentTopicIdFromController(popup) || null
|
|
29
|
+
}),
|
|
22
30
|
labels: {
|
|
23
31
|
submit: menu.dataset.formSubmit || 'OK',
|
|
24
32
|
cancel: menu.dataset.formCancel || 'Cancel'
|
|
@@ -79,6 +87,25 @@ if (!commandMenuInitialized) {
|
|
|
79
87
|
}
|
|
80
88
|
})
|
|
81
89
|
|
|
90
|
+
function currentTopicIdFromController(popupEl) {
|
|
91
|
+
// Prefer the form controller's locally-cached topic id — the same value
|
|
92
|
+
// used when submitting a comment. It is set from the
|
|
93
|
+
// `comments--topics:change` event, so it reflects the user's actual
|
|
94
|
+
// selection after restoreSelection rather than a stale URL deep-link.
|
|
95
|
+
const formCtrl = window.Stimulus?.getControllerForElementAndIdentifier(popupEl, 'comments--form')
|
|
96
|
+
if (formCtrl) {
|
|
97
|
+
const id = formCtrl.currentTopicId || formCtrl._mainTopicId
|
|
98
|
+
if (id) return String(id)
|
|
99
|
+
}
|
|
100
|
+
// Topics may not have loaded yet (form hasn't received any change event).
|
|
101
|
+
// Fall back to mainTopicId only — never topicsCtrl.currentTopicId, whose
|
|
102
|
+
// getter prioritizes window.location.search?topic_id= even when that
|
|
103
|
+
// topic does not belong to the current creative.
|
|
104
|
+
const topicsCtrl = window.Stimulus?.getControllerForElementAndIdentifier(popupEl, 'comments--topics')
|
|
105
|
+
if (!topicsCtrl) return ''
|
|
106
|
+
return topicsCtrl.mainTopicId ? String(topicsCtrl.mainTopicId) : ''
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
function clearCommandText() {
|
|
83
110
|
const pos = textarea.selectionStart
|
|
84
111
|
const after = textarea.value.slice(pos)
|
|
@@ -4,6 +4,9 @@ import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $i
|
|
|
4
4
|
import { createInlineEditor } from './lexical_inline_editor'
|
|
5
5
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
|
|
6
6
|
import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
|
|
7
|
+
import { renderMarkdown } from '../lib/utils/markdown'
|
|
8
|
+
import { reconcileMarkdownSource } from './markdown_source_reconcile'
|
|
9
|
+
import { isHtmlEmpty } from './html_content_empty'
|
|
7
10
|
import yaml from 'js-yaml'
|
|
8
11
|
// Import Stimulus application from the global window (set by host app)
|
|
9
12
|
const application = window.Stimulus
|
|
@@ -107,6 +110,16 @@ export function initializeCreativeRowEditor() {
|
|
|
107
110
|
const metadataSaveBtn = document.getElementById('metadata-save-btn');
|
|
108
111
|
const metadataCloseBtn = document.getElementById('metadata-popup-close');
|
|
109
112
|
|
|
113
|
+
// Markdown editor elements
|
|
114
|
+
const contentTypeInput = document.getElementById('inline-content-type');
|
|
115
|
+
const markdownSourceInput = document.getElementById('inline-markdown-source');
|
|
116
|
+
const markdownWrapper = document.getElementById('markdown-editor-wrapper');
|
|
117
|
+
const markdownTextarea = document.getElementById('markdown-editor-textarea');
|
|
118
|
+
const markdownPreview = document.getElementById('markdown-preview');
|
|
119
|
+
const toggleMarkdownBtn = document.getElementById('inline-toggle-markdown');
|
|
120
|
+
let markdownMode = false;
|
|
121
|
+
let markdownPreviewTimer = null;
|
|
122
|
+
|
|
110
123
|
let lexicalEditor = null;
|
|
111
124
|
if (editorContainer) {
|
|
112
125
|
try {
|
|
@@ -239,6 +252,12 @@ export function initializeCreativeRowEditor() {
|
|
|
239
252
|
if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
|
|
240
253
|
setRowDatasetValue(row, 'originId', data.origin_id ?? '');
|
|
241
254
|
}
|
|
255
|
+
if (Object.prototype.hasOwnProperty.call(data, 'content_type')) {
|
|
256
|
+
setRowDatasetValue(row, 'contentType', data.content_type ?? '');
|
|
257
|
+
}
|
|
258
|
+
if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
|
|
259
|
+
setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
|
|
260
|
+
}
|
|
242
261
|
if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
|
|
243
262
|
if (data.has_children) {
|
|
244
263
|
row.setAttribute('has-children', '');
|
|
@@ -277,16 +296,47 @@ export function initializeCreativeRowEditor() {
|
|
|
277
296
|
description_raw_html: rawHtml,
|
|
278
297
|
origin_id: row.dataset?.originId || '',
|
|
279
298
|
parent_id: parentId,
|
|
280
|
-
progress: Number.isNaN(progressValue) ? 0 : progressValue
|
|
299
|
+
progress: Number.isNaN(progressValue) ? 0 : progressValue,
|
|
300
|
+
content_type: row.dataset?.contentType || null,
|
|
301
|
+
markdown_source: row.dataset?.markdownSource || null
|
|
281
302
|
};
|
|
282
303
|
}
|
|
283
304
|
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
305
|
+
function isMarkdownEmpty(md) {
|
|
306
|
+
return !md || md.trim().length === 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function activateMarkdownMode(source) {
|
|
310
|
+
markdownMode = true;
|
|
311
|
+
if (contentTypeInput) contentTypeInput.value = 'markdown';
|
|
312
|
+
if (markdownTextarea) markdownTextarea.value = source || '';
|
|
313
|
+
if (markdownWrapper) markdownWrapper.style.display = '';
|
|
314
|
+
if (editorContainer) editorContainer.style.display = 'none';
|
|
315
|
+
if (markdownPreview) markdownPreview.innerHTML = source ? renderMarkdown(source) : '';
|
|
316
|
+
if (toggleMarkdownBtn) {
|
|
317
|
+
toggleMarkdownBtn.textContent = toggleMarkdownBtn.dataset.labelRichtext || 'Rich Text';
|
|
318
|
+
toggleMarkdownBtn.classList.add('active');
|
|
319
|
+
}
|
|
320
|
+
if (markdownTextarea) markdownTextarea.focus();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function deactivateMarkdownMode() {
|
|
324
|
+
markdownMode = false;
|
|
325
|
+
if (contentTypeInput) contentTypeInput.value = 'html';
|
|
326
|
+
if (markdownSourceInput) markdownSourceInput.value = '';
|
|
327
|
+
if (markdownWrapper) markdownWrapper.style.display = 'none';
|
|
328
|
+
if (editorContainer) editorContainer.style.display = '';
|
|
329
|
+
if (toggleMarkdownBtn) {
|
|
330
|
+
toggleMarkdownBtn.textContent = toggleMarkdownBtn.dataset.labelMarkdown || 'MD';
|
|
331
|
+
toggleMarkdownBtn.classList.remove('active');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function syncMarkdownToForm() {
|
|
336
|
+
if (!markdownMode) return;
|
|
337
|
+
const md = markdownTextarea ? markdownTextarea.value : '';
|
|
338
|
+
if (markdownSourceInput) markdownSourceInput.value = md;
|
|
339
|
+
if (descriptionInput) descriptionInput.value = renderMarkdown(md);
|
|
290
340
|
}
|
|
291
341
|
|
|
292
342
|
function applyCreativeData(data, tree) {
|
|
@@ -298,10 +348,21 @@ export function initializeCreativeRowEditor() {
|
|
|
298
348
|
form.dataset.creativeId = creativeId;
|
|
299
349
|
const content = data.description_raw_html || data.description || '';
|
|
300
350
|
descriptionInput.value = content;
|
|
301
|
-
|
|
351
|
+
|
|
352
|
+
// Handle markdown vs rich text mode
|
|
353
|
+
const isMarkdown = data.content_type === 'markdown';
|
|
354
|
+
if (isMarkdown) {
|
|
355
|
+
activateMarkdownMode(data.markdown_source || '');
|
|
356
|
+
// Also load Lexical with HTML for fallback/switching
|
|
357
|
+
lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
|
|
358
|
+
} else {
|
|
359
|
+
deactivateMarkdownMode();
|
|
360
|
+
lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
302
363
|
pendingSave = false;
|
|
303
364
|
// Track original content for dirty state detection
|
|
304
|
-
originalContent = content;
|
|
365
|
+
originalContent = isMarkdown ? (data.markdown_source || '') : content;
|
|
305
366
|
isDirty = false;
|
|
306
367
|
const progressNumber = Number(data.progress ?? 0);
|
|
307
368
|
const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
|
|
@@ -323,7 +384,9 @@ export function initializeCreativeRowEditor() {
|
|
|
323
384
|
const effectiveParent = parentInput.value;
|
|
324
385
|
if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
|
|
325
386
|
originalProgress = normalizedProgress;
|
|
326
|
-
|
|
387
|
+
if (!isMarkdown) {
|
|
388
|
+
lexicalEditor.focus();
|
|
389
|
+
}
|
|
327
390
|
updateActionButtonStates();
|
|
328
391
|
// Reload metadata if the popup is open
|
|
329
392
|
if (isMetadataPopupVisible()) {
|
|
@@ -973,7 +1036,13 @@ export function initializeCreativeRowEditor() {
|
|
|
973
1036
|
if (saving) return savePromise;
|
|
974
1037
|
clearTimeout(saveTimer);
|
|
975
1038
|
|
|
976
|
-
|
|
1039
|
+
// Sync markdown form fields before saving
|
|
1040
|
+
if (markdownMode) syncMarkdownToForm();
|
|
1041
|
+
|
|
1042
|
+
const isEmpty = markdownMode
|
|
1043
|
+
? isMarkdownEmpty(markdownTextarea?.value)
|
|
1044
|
+
: isHtmlEmpty(descriptionInput.value);
|
|
1045
|
+
if (isEmpty) {
|
|
977
1046
|
pendingSave = false;
|
|
978
1047
|
return Promise.resolve();
|
|
979
1048
|
}
|
|
@@ -984,7 +1053,9 @@ export function initializeCreativeRowEditor() {
|
|
|
984
1053
|
saving = true;
|
|
985
1054
|
|
|
986
1055
|
// Capture values being saved to update dirty state on success
|
|
987
|
-
const
|
|
1056
|
+
// NOTE: `let` (not `const`) — when the server rewrites markdown_source
|
|
1057
|
+
// (e.g. data: URI → blob path) we reassign below.
|
|
1058
|
+
let savedContent = markdownMode ? (markdownTextarea?.value || '') : descriptionInput.value;
|
|
988
1059
|
const shouldPersistProgress = progressValueChanged();
|
|
989
1060
|
const savedProgress = shouldPersistProgress ? readProgressValue() : progressBaselineValueFrom(originalProgress);
|
|
990
1061
|
const savedOriginId = originIdInput ? originIdInput.value : '';
|
|
@@ -1002,6 +1073,25 @@ export function initializeCreativeRowEditor() {
|
|
|
1002
1073
|
return r.text().then(function (text) {
|
|
1003
1074
|
try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
|
|
1004
1075
|
}).then(function (data) {
|
|
1076
|
+
// Sync rewritten markdown source back into the textarea/hidden input.
|
|
1077
|
+
// Server rewrites inline data: URIs in markdown_source to blob paths so
|
|
1078
|
+
// re-saves don't re-import the same image. If the user typed during the
|
|
1079
|
+
// request, merge the substitutions into the live textarea so the next
|
|
1080
|
+
// save still carries blob paths instead of re-importing the data URI.
|
|
1081
|
+
if (markdownMode && data && typeof data.markdown_source === 'string'
|
|
1082
|
+
&& data.markdown_source !== savedContent && markdownTextarea) {
|
|
1083
|
+
const reconciled = reconcileMarkdownSource(
|
|
1084
|
+
savedContent, data.markdown_source, markdownTextarea.value
|
|
1085
|
+
);
|
|
1086
|
+
if (reconciled !== null && reconciled !== markdownTextarea.value) {
|
|
1087
|
+
markdownTextarea.value = reconciled;
|
|
1088
|
+
syncMarkdownToForm();
|
|
1089
|
+
}
|
|
1090
|
+
if (reconciled !== null) {
|
|
1091
|
+
savedContent = data.markdown_source;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1005
1095
|
// Update dirty state to reflect successful save
|
|
1006
1096
|
originalContent = savedContent;
|
|
1007
1097
|
if (shouldPersistProgress) {
|
|
@@ -1010,7 +1100,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1010
1100
|
originalOriginId = savedOriginId;
|
|
1011
1101
|
|
|
1012
1102
|
// If current values match what was just saved, clear dirty flag
|
|
1013
|
-
|
|
1103
|
+
const currentContent = markdownMode ? (markdownTextarea?.value || '') : descriptionInput.value;
|
|
1104
|
+
if (currentContent === savedContent &&
|
|
1014
1105
|
readProgressValue() === savedProgress &&
|
|
1015
1106
|
originIdInput.value === savedOriginId) {
|
|
1016
1107
|
isDirty = false;
|
|
@@ -1197,6 +1288,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1197
1288
|
|
|
1198
1289
|
// CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
|
|
1199
1290
|
// to a different creative while we're waiting for uploads
|
|
1291
|
+
const isMarkdownSave = markdownMode;
|
|
1292
|
+
if (isMarkdownSave) syncMarkdownToForm();
|
|
1200
1293
|
let currentContent = descriptionInput.value;
|
|
1201
1294
|
let currentProgress = readProgressValue();
|
|
1202
1295
|
let shouldPersistProgress = progressValueChanged();
|
|
@@ -1204,10 +1297,15 @@ export function initializeCreativeRowEditor() {
|
|
|
1204
1297
|
const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
|
|
1205
1298
|
const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
|
|
1206
1299
|
const startCreativeId = creativeId;
|
|
1300
|
+
let capturedMarkdownSource = isMarkdownSave ? (markdownTextarea?.value || '') : '';
|
|
1301
|
+
const capturedContentType = isMarkdownSave ? 'markdown' : 'html';
|
|
1207
1302
|
|
|
1208
1303
|
// Prevent saving empty content, matching saveForm behavior
|
|
1209
1304
|
// This avoids overwriting existing descriptions with empty strings during quick navigation
|
|
1210
|
-
|
|
1305
|
+
const isEmpty = isMarkdownSave
|
|
1306
|
+
? isMarkdownEmpty(capturedMarkdownSource)
|
|
1307
|
+
: isHtmlEmpty(currentContent);
|
|
1308
|
+
if (isEmpty) {
|
|
1211
1309
|
pendingSave = false;
|
|
1212
1310
|
return;
|
|
1213
1311
|
}
|
|
@@ -1217,8 +1315,15 @@ export function initializeCreativeRowEditor() {
|
|
|
1217
1315
|
await waitForUploads();
|
|
1218
1316
|
|
|
1219
1317
|
// If we are still on the same creative (e.g. move awaited us), refresh the content
|
|
1220
|
-
// This ensures we capture the final HTML with signed IDs instead of blob URLs
|
|
1318
|
+
// This ensures we capture the final HTML with signed IDs instead of blob URLs.
|
|
1319
|
+
// Markdown saves regenerate description from creative[markdown_source] server-side,
|
|
1320
|
+
// so we must re-sync and re-capture the latest textarea value too — otherwise edits
|
|
1321
|
+
// made during the upload wait get overwritten by the stale pre-wait source.
|
|
1221
1322
|
if (form.dataset.creativeId === startCreativeId) {
|
|
1323
|
+
if (isMarkdownSave) {
|
|
1324
|
+
syncMarkdownToForm();
|
|
1325
|
+
capturedMarkdownSource = markdownTextarea?.value || '';
|
|
1326
|
+
}
|
|
1222
1327
|
currentContent = descriptionInput.value;
|
|
1223
1328
|
currentProgress = readProgressValue();
|
|
1224
1329
|
shouldPersistProgress = progressValueChanged();
|
|
@@ -1228,8 +1333,12 @@ export function initializeCreativeRowEditor() {
|
|
|
1228
1333
|
// Note: before_id and after_id must be top-level params, not nested under creative[]
|
|
1229
1334
|
// because CreativesController reads params[:before_id] and params[:after_id] for positioning
|
|
1230
1335
|
const body = {
|
|
1231
|
-
'creative[description]': currentContent
|
|
1336
|
+
'creative[description]': currentContent,
|
|
1337
|
+
'creative[content_type_input]': capturedContentType
|
|
1232
1338
|
};
|
|
1339
|
+
if (isMarkdownSave) {
|
|
1340
|
+
body['creative[markdown_source]'] = capturedMarkdownSource;
|
|
1341
|
+
}
|
|
1233
1342
|
|
|
1234
1343
|
if (shouldPersistProgress) {
|
|
1235
1344
|
body['creative[progress]'] = currentProgress;
|
|
@@ -1257,6 +1366,8 @@ export function initializeCreativeRowEditor() {
|
|
|
1257
1366
|
if (shouldPersistProgress) {
|
|
1258
1367
|
row.dataset.progressValue = String(currentProgress);
|
|
1259
1368
|
}
|
|
1369
|
+
row.dataset.contentType = capturedContentType;
|
|
1370
|
+
row.dataset.markdownSource = isMarkdownSave ? capturedMarkdownSource : '';
|
|
1260
1371
|
if (currentParentId) {
|
|
1261
1372
|
tree.dataset.parentId = currentParentId;
|
|
1262
1373
|
row.parentId = currentParentId;
|
|
@@ -1279,12 +1390,49 @@ export function initializeCreativeRowEditor() {
|
|
|
1279
1390
|
|
|
1280
1391
|
// Queue the save request
|
|
1281
1392
|
// Store deletedAttachmentIds as data, not as callback, so it can be serialized
|
|
1393
|
+
// Capture per-enqueue values for the onSuccess closure so concurrent edits
|
|
1394
|
+
// on a different creative don't get clobbered when the response comes back.
|
|
1395
|
+
const onSuccessCreativeId = startCreativeId;
|
|
1396
|
+
const onSuccessSavedMarkdown = isMarkdownSave ? capturedMarkdownSource : null;
|
|
1397
|
+
const onSuccessTree = tree;
|
|
1282
1398
|
apiQueue.enqueue({
|
|
1283
1399
|
path: `/creatives/${creativeId}`,
|
|
1284
1400
|
method: 'PATCH',
|
|
1285
1401
|
body: body,
|
|
1286
1402
|
dedupeKey: `creative_${creativeId}`,
|
|
1287
|
-
deletedAttachmentIds: deletedAttachmentIds // Store as data for serialization
|
|
1403
|
+
deletedAttachmentIds: deletedAttachmentIds, // Store as data for serialization
|
|
1404
|
+
onSuccess: function (data) {
|
|
1405
|
+
if (!isMarkdownSave || !data || typeof data.markdown_source !== 'string') return;
|
|
1406
|
+
if (data.markdown_source === onSuccessSavedMarkdown) return;
|
|
1407
|
+
|
|
1408
|
+
// Update the row dataset cache regardless of which creative is active now,
|
|
1409
|
+
// so a later loadCreative() for this row picks up the rewritten source.
|
|
1410
|
+
if (onSuccessTree) {
|
|
1411
|
+
const row = treeRowElement(onSuccessTree);
|
|
1412
|
+
if (row && row.dataset.markdownSource === onSuccessSavedMarkdown) {
|
|
1413
|
+
row.dataset.markdownSource = data.markdown_source;
|
|
1414
|
+
row.requestUpdate?.();
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Merge the data: URI -> blob path substitutions into the live textarea,
|
|
1419
|
+
// even if the user typed during the queued save. We still require the
|
|
1420
|
+
// same creative to be open (race-safe across editor switches).
|
|
1421
|
+
if (form.dataset.creativeId === onSuccessCreativeId
|
|
1422
|
+
&& markdownMode
|
|
1423
|
+
&& markdownTextarea) {
|
|
1424
|
+
const reconciled = reconcileMarkdownSource(
|
|
1425
|
+
onSuccessSavedMarkdown, data.markdown_source, markdownTextarea.value
|
|
1426
|
+
);
|
|
1427
|
+
if (reconciled !== null && reconciled !== markdownTextarea.value) {
|
|
1428
|
+
markdownTextarea.value = reconciled;
|
|
1429
|
+
syncMarkdownToForm();
|
|
1430
|
+
}
|
|
1431
|
+
if (reconciled !== null) {
|
|
1432
|
+
originalContent = data.markdown_source;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1288
1436
|
});
|
|
1289
1437
|
// console.warn('apiQueue.enqueue disabled for debugging');
|
|
1290
1438
|
|
|
@@ -1739,6 +1887,7 @@ export function initializeCreativeRowEditor() {
|
|
|
1739
1887
|
afterInput.value = afterId || '';
|
|
1740
1888
|
if (childInput) childInput.value = childId || '';
|
|
1741
1889
|
resetOriginTracking();
|
|
1890
|
+
deactivateMarkdownMode();
|
|
1742
1891
|
descriptionInput.value = '';
|
|
1743
1892
|
lexicalEditor.reset(`new-${Date.now()}`);
|
|
1744
1893
|
setProgressState(0);
|
|
@@ -1781,12 +1930,25 @@ export function initializeCreativeRowEditor() {
|
|
|
1781
1930
|
}
|
|
1782
1931
|
|
|
1783
1932
|
function onLexicalChange(html) {
|
|
1933
|
+
if (markdownMode) return; // Ignore Lexical changes in markdown mode
|
|
1784
1934
|
descriptionInput.value = html;
|
|
1785
1935
|
// Mark as dirty if content changed from original
|
|
1786
1936
|
isDirty = (html !== originalContent);
|
|
1787
1937
|
scheduleSave();
|
|
1788
1938
|
}
|
|
1789
1939
|
|
|
1940
|
+
function onMarkdownTextareaInput() {
|
|
1941
|
+
const md = markdownTextarea.value;
|
|
1942
|
+
syncMarkdownToForm();
|
|
1943
|
+
isDirty = (md !== originalContent);
|
|
1944
|
+
scheduleSave();
|
|
1945
|
+
// Debounced live preview
|
|
1946
|
+
clearTimeout(markdownPreviewTimer);
|
|
1947
|
+
markdownPreviewTimer = setTimeout(() => {
|
|
1948
|
+
if (markdownPreview) markdownPreview.innerHTML = renderMarkdown(md);
|
|
1949
|
+
}, 300);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1790
1952
|
// Intercepts Shift+Enter via capture-phase keydown on Lexical's root element.
|
|
1791
1953
|
// Returning true triggers preventDefault + stopImmediatePropagation.
|
|
1792
1954
|
// Shift+Enter → addNew (save & add next)
|
|
@@ -2174,5 +2336,53 @@ export function initializeCreativeRowEditor() {
|
|
|
2174
2336
|
});
|
|
2175
2337
|
}
|
|
2176
2338
|
}
|
|
2339
|
+
|
|
2340
|
+
// Markdown toggle button
|
|
2341
|
+
if (toggleMarkdownBtn) {
|
|
2342
|
+
toggleMarkdownBtn.addEventListener('click', function () {
|
|
2343
|
+
if (markdownMode) {
|
|
2344
|
+
// Switching from Markdown → Rich Text
|
|
2345
|
+
const confirmMsg = toggleMarkdownBtn.dataset.confirmToRichtext;
|
|
2346
|
+
if (confirmMsg && !confirm(confirmMsg)) return;
|
|
2347
|
+
const md = markdownTextarea?.value || '';
|
|
2348
|
+
const html = md ? renderMarkdown(md) : '';
|
|
2349
|
+
deactivateMarkdownMode();
|
|
2350
|
+
descriptionInput.value = html;
|
|
2351
|
+
lexicalEditor.load(html, `creative-switch-${Date.now()}`);
|
|
2352
|
+
lexicalEditor.focus();
|
|
2353
|
+
isDirty = true;
|
|
2354
|
+
scheduleSave();
|
|
2355
|
+
} else {
|
|
2356
|
+
// Switching from Rich Text → Markdown
|
|
2357
|
+
const currentHtml = descriptionInput.value || '';
|
|
2358
|
+
if (!isHtmlEmpty(currentHtml)) {
|
|
2359
|
+
const confirmMsg = toggleMarkdownBtn.dataset.confirmToMarkdown;
|
|
2360
|
+
if (confirmMsg && !confirm(confirmMsg)) return;
|
|
2361
|
+
}
|
|
2362
|
+
activateMarkdownMode('');
|
|
2363
|
+
isDirty = true;
|
|
2364
|
+
scheduleSave();
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// Markdown textarea input handler
|
|
2370
|
+
if (markdownTextarea) {
|
|
2371
|
+
markdownTextarea.addEventListener('input', onMarkdownTextareaInput);
|
|
2372
|
+
|
|
2373
|
+
// Support keyboard shortcuts in markdown textarea
|
|
2374
|
+
markdownTextarea.addEventListener('keydown', function (event) {
|
|
2375
|
+
if (event.key === 'Escape') {
|
|
2376
|
+
event.preventDefault();
|
|
2377
|
+
hideCurrent();
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
// Shift+Enter → add new sibling (same as Lexical)
|
|
2381
|
+
if (event.key === 'Enter' && event.shiftKey) {
|
|
2382
|
+
event.preventDefault();
|
|
2383
|
+
addNew();
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2177
2387
|
});
|
|
2178
2388
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// True when an HTML fragment has no user-visible content. Treats inline
|
|
2
|
+
// images and attachments (action-text-attachment / figure.attachment /
|
|
3
|
+
// data-trix-attachment) as content so image-only or attachment-only bodies
|
|
4
|
+
// don't get discarded silently when switching editor modes.
|
|
5
|
+
export function isHtmlEmpty(html, doc = typeof document !== 'undefined' ? document : null) {
|
|
6
|
+
if (!html) return true;
|
|
7
|
+
if (!doc) return html.replace(/<[^>]*>/g, '').trim().length === 0;
|
|
8
|
+
const temp = doc.createElement('div');
|
|
9
|
+
temp.innerHTML = html;
|
|
10
|
+
if (temp.querySelector('img, action-text-attachment, figure.attachment, [data-trix-attachment]')) return false;
|
|
11
|
+
return (temp.textContent || '').trim().length === 0;
|
|
12
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Reconcile a server-rewritten markdown source with the current textarea value
|
|
2
|
+
// when the user typed during the save. The server's rewrite is a pure
|
|
3
|
+
// data: URI -> blob path substitution on what we sent (`savedContent`).
|
|
4
|
+
// If every data: URI we sent is still present in `currentValue`, apply the
|
|
5
|
+
// same substitutions there and return the merged value; otherwise return null.
|
|
6
|
+
export function reconcileMarkdownSource(savedContent, rewritten, currentValue) {
|
|
7
|
+
if (typeof savedContent !== 'string' || typeof rewritten !== 'string'
|
|
8
|
+
|| typeof currentValue !== 'string') return null;
|
|
9
|
+
if (rewritten === savedContent) return null;
|
|
10
|
+
if (currentValue === savedContent) return rewritten;
|
|
11
|
+
|
|
12
|
+
const dataUriRegex = /data:image\/[^\s)>"'`\]]+/g;
|
|
13
|
+
const matches = [];
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = dataUriRegex.exec(savedContent)) !== null) {
|
|
16
|
+
matches.push({ uri: m[0], start: m.index, end: m.index + m[0].length });
|
|
17
|
+
}
|
|
18
|
+
if (matches.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
const pairs = [];
|
|
21
|
+
let savedPos = 0;
|
|
22
|
+
let rewrittenPos = 0;
|
|
23
|
+
for (let i = 0; i < matches.length; i++) {
|
|
24
|
+
const match = matches[i];
|
|
25
|
+
const textLen = match.start - savedPos;
|
|
26
|
+
if (rewritten.slice(rewrittenPos, rewrittenPos + textLen)
|
|
27
|
+
!== savedContent.slice(savedPos, match.start)) return null;
|
|
28
|
+
rewrittenPos += textLen;
|
|
29
|
+
savedPos = match.end;
|
|
30
|
+
|
|
31
|
+
const nextStart = (i + 1 < matches.length) ? matches[i + 1].start : savedContent.length;
|
|
32
|
+
const tail = savedContent.slice(savedPos, nextStart);
|
|
33
|
+
let blobEnd;
|
|
34
|
+
if (tail.length === 0) {
|
|
35
|
+
blobEnd = rewritten.length;
|
|
36
|
+
} else {
|
|
37
|
+
blobEnd = rewritten.indexOf(tail, rewrittenPos);
|
|
38
|
+
if (blobEnd === -1) return null;
|
|
39
|
+
}
|
|
40
|
+
pairs.push({ from: match.uri, to: rewritten.slice(rewrittenPos, blobEnd) });
|
|
41
|
+
rewrittenPos = blobEnd;
|
|
42
|
+
}
|
|
43
|
+
if (rewritten.slice(rewrittenPos) !== savedContent.slice(savedPos)) return null;
|
|
44
|
+
|
|
45
|
+
let result = currentValue;
|
|
46
|
+
for (const pair of pairs) {
|
|
47
|
+
if (pair.from === pair.to) continue;
|
|
48
|
+
const idx = result.indexOf(pair.from);
|
|
49
|
+
if (idx === -1) return null;
|
|
50
|
+
result = result.slice(0, idx) + pair.to + result.slice(idx + pair.from.length);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
@@ -8,6 +8,8 @@ module Collavre
|
|
|
8
8
|
# This triggers retry_on instead of silently succeeding.
|
|
9
9
|
class DispatchFailedError < StandardError; end
|
|
10
10
|
|
|
11
|
+
DROP_TRIGGER_TOPIC_NAME = "Drop Trigger"
|
|
12
|
+
|
|
11
13
|
retry_on DispatchFailedError, wait: 5.seconds, attempts: 3
|
|
12
14
|
|
|
13
15
|
# End-to-end idempotent: safe to retry at any point.
|
|
@@ -57,9 +59,8 @@ module Collavre
|
|
|
57
59
|
private
|
|
58
60
|
|
|
59
61
|
def post_trigger_failure_notice(child, parent)
|
|
60
|
-
topic = child.topics.
|
|
61
|
-
|
|
62
|
-
end
|
|
62
|
+
topic = child.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME) ||
|
|
63
|
+
create_drop_trigger_topic(child)
|
|
63
64
|
post_system_notice(child, topic, I18n.t(
|
|
64
65
|
"collavre.drop_trigger.no_agent",
|
|
65
66
|
parent_description: parent.creative_snippet
|
|
@@ -82,17 +83,45 @@ module Collavre
|
|
|
82
83
|
end
|
|
83
84
|
|
|
84
85
|
def find_or_create_trigger_topic(creative, agent)
|
|
85
|
-
topic = creative.topics.find_by(name:
|
|
86
|
+
topic = creative.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME)
|
|
86
87
|
return topic if topic
|
|
87
88
|
|
|
88
|
-
topic = creative
|
|
89
|
-
name: "Drop Trigger",
|
|
90
|
-
user: creative.user
|
|
91
|
-
)
|
|
89
|
+
topic = create_drop_trigger_topic(creative)
|
|
92
90
|
topic.set_primary_agent!(agent)
|
|
93
91
|
topic
|
|
94
92
|
end
|
|
95
93
|
|
|
94
|
+
# Creates the Drop Trigger topic, branching from the creative's Main topic
|
|
95
|
+
# when Main has messages. Equivalent to the user manually selecting every
|
|
96
|
+
# Main message and pressing "branch" — only the resulting topic name is
|
|
97
|
+
# fixed to "Drop Trigger".
|
|
98
|
+
def create_drop_trigger_topic(creative)
|
|
99
|
+
main = creative.main_topic(fallback_user: creative.user)
|
|
100
|
+
# Mirror the manual "select all → branch" flow: only comments the owner
|
|
101
|
+
# can see are copied. enforce_limit: false bypasses the UI's 100-comment
|
|
102
|
+
# selection cap so full Main history transfers regardless of length.
|
|
103
|
+
main_comment_ids = main.comments
|
|
104
|
+
.visible_to(creative.user)
|
|
105
|
+
.order(:created_at)
|
|
106
|
+
.pluck(:id)
|
|
107
|
+
|
|
108
|
+
if main_comment_ids.any?
|
|
109
|
+
# auto_select: false prevents the broadcast from hijacking the owner's
|
|
110
|
+
# current topic selection while this background job runs.
|
|
111
|
+
TopicBranchService.new(
|
|
112
|
+
creative: creative,
|
|
113
|
+
user: creative.user,
|
|
114
|
+
source_topic: main,
|
|
115
|
+
name: DROP_TRIGGER_TOPIC_NAME
|
|
116
|
+
).call(comment_ids: main_comment_ids, enforce_limit: false, auto_select: false)
|
|
117
|
+
else
|
|
118
|
+
creative.topics.create!(
|
|
119
|
+
name: DROP_TRIGGER_TOPIC_NAME,
|
|
120
|
+
user: creative.user
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
96
125
|
def trigger_content_key(child, parent)
|
|
97
126
|
I18n.t(
|
|
98
127
|
"collavre.drop_trigger.child_entered",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class ApplicationMailer < ActionMailer::Base
|
|
3
|
-
default from:
|
|
3
|
+
default from: -> { Collavre::IntegrationSettings.fetch(:default_mailer_from, default: "no-reply@example.com") }
|
|
4
4
|
layout "mailer"
|
|
5
5
|
|
|
6
6
|
private
|