collavre_notion 0.1.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.
@@ -0,0 +1,506 @@
1
+ let notionIntegrationInitialized = false;
2
+
3
+ if (!notionIntegrationInitialized) {
4
+ notionIntegrationInitialized = true;
5
+
6
+ document.addEventListener('turbo:load', function () {
7
+ const openBtn = document.getElementById('notion-integration-btn');
8
+ const modal = document.getElementById('notion-integration-modal');
9
+ if (!openBtn || !modal) return;
10
+
11
+ const statusEl = document.getElementById('notion-integration-status');
12
+ const loginBtn = document.getElementById('notion-login-btn');
13
+ const loginForm = document.getElementById('notion-login-form');
14
+ const closeBtn = document.getElementById('close-notion-modal');
15
+ const prevBtn = document.getElementById('notion-prev-btn');
16
+ const nextBtn = document.getElementById('notion-next-btn');
17
+ const exportBtn = document.getElementById('notion-export-btn');
18
+ const syncBtn = document.getElementById('notion-sync-btn');
19
+ const deleteBtn = document.getElementById('notion-delete-btn');
20
+ const errorEl = document.getElementById('notion-wizard-error');
21
+ const existingContainer = document.getElementById('notion-existing-connections');
22
+ const existingList = document.getElementById('notion-existing-page-list');
23
+ const connectMessage = document.getElementById('notion-connect-message');
24
+ const workspaceNameEl = document.getElementById('notion-workspace-name');
25
+ const parentPageSection = document.getElementById('notion-parent-page-section');
26
+ const parentPageSelect = document.getElementById('notion-parent-page-select');
27
+ const creativeTitleEl = document.getElementById('notion-creative-title');
28
+ const workspaceSummaryEl = document.getElementById('notion-workspace-summary');
29
+ const exportTypeSummaryEl = document.getElementById('notion-export-type-summary');
30
+ const parentSummaryEl = document.getElementById('notion-parent-summary');
31
+ const parentPageSummaryEl = document.getElementById('notion-parent-page-summary');
32
+
33
+ let creativeId = null;
34
+ let currentStep = 'connect';
35
+ let hasExistingIntegration = false;
36
+ let workspaceInfo = null;
37
+ let availablePages = [];
38
+ let exportType = 'new-page';
39
+ let selectedParentPage = null;
40
+
41
+ function csrfToken() {
42
+ return document.querySelector('meta[name="csrf-token"]')?.content;
43
+ }
44
+
45
+ function resetWizard() {
46
+ currentStep = 'connect';
47
+ hasExistingIntegration = false;
48
+ workspaceInfo = null;
49
+ availablePages = [];
50
+ exportType = 'new-page';
51
+ selectedParentPage = null;
52
+ statusEl.textContent = '';
53
+ errorEl.style.display = 'none';
54
+ errorEl.textContent = '';
55
+ if (existingContainer) {
56
+ existingContainer.style.display = 'none';
57
+ }
58
+ if (existingList) {
59
+ existingList.innerHTML = '';
60
+ }
61
+ if (connectMessage) {
62
+ connectMessage.style.display = '';
63
+ }
64
+ if (deleteBtn) deleteBtn.style.display = 'none';
65
+ if (syncBtn) syncBtn.style.display = 'none';
66
+ if (loginBtn) loginBtn.style.display = 'inline-block';
67
+ if (parentPageSection) parentPageSection.style.display = 'none';
68
+ updateStep();
69
+ }
70
+
71
+ function updateStep() {
72
+ ['connect', 'workspace', 'summary']
73
+ .forEach(function (step) {
74
+ const el = document.getElementById(`notion-step-${step}`);
75
+ if (!el) return;
76
+ el.style.display = (step === currentStep) ? 'block' : 'none';
77
+ });
78
+
79
+ if (currentStep === 'connect') {
80
+ prevBtn.style.display = 'none';
81
+ if (hasExistingIntegration) {
82
+ nextBtn.style.display = 'block';
83
+ nextBtn.disabled = false;
84
+ } else {
85
+ nextBtn.style.display = 'none';
86
+ }
87
+ exportBtn.style.display = 'none';
88
+ } else if (currentStep === 'workspace') {
89
+ prevBtn.style.display = 'block';
90
+ prevBtn.disabled = false;
91
+ nextBtn.style.display = 'block';
92
+ nextBtn.disabled = false;
93
+ exportBtn.style.display = 'none';
94
+ } else if (currentStep === 'summary') {
95
+ prevBtn.style.display = 'block';
96
+ prevBtn.disabled = false;
97
+ nextBtn.style.display = 'none';
98
+ exportBtn.style.display = 'block';
99
+ exportBtn.disabled = false;
100
+ }
101
+ }
102
+
103
+ function showError(message) {
104
+ errorEl.textContent = message;
105
+ errorEl.style.display = 'block';
106
+ }
107
+
108
+ function clearError() {
109
+ errorEl.style.display = 'none';
110
+ errorEl.textContent = '';
111
+ }
112
+
113
+ function loadIntegrationStatus() {
114
+ if (!creativeId) return;
115
+
116
+ statusEl.textContent = 'Loading...';
117
+ clearError();
118
+
119
+ fetch(`/notion/creatives/${creativeId}/notion_integration`, {
120
+ method: 'GET',
121
+ headers: {
122
+ 'X-CSRF-Token': csrfToken(),
123
+ 'Content-Type': 'application/json'
124
+ }
125
+ })
126
+ .then(response => response.json())
127
+ .then(data => {
128
+ console.log('Notion integration status:', data);
129
+ statusEl.textContent = '';
130
+
131
+ // Store the creative title from the API response
132
+ if (data.creative_title) {
133
+ window.notionCreativeTitle = data.creative_title;
134
+ console.log('Creative title from API:', data.creative_title);
135
+ }
136
+
137
+ if (data.connected) {
138
+ workspaceInfo = data.account;
139
+ availablePages = data.available_pages || [];
140
+
141
+ console.log('Available pages:', availablePages);
142
+
143
+ if (workspaceNameEl) {
144
+ workspaceNameEl.textContent = data.account.workspace_name || 'Notion Workspace';
145
+ }
146
+
147
+ if (data.linked_pages && data.linked_pages.length > 0) {
148
+ hasExistingIntegration = true;
149
+ showExistingIntegration(data.linked_pages);
150
+ } else {
151
+ hasExistingIntegration = true;
152
+ if (connectMessage) connectMessage.style.display = 'none';
153
+ if (loginBtn) loginBtn.style.display = 'none';
154
+ }
155
+
156
+ updateParentPageSelect(); // Update the select with available pages
157
+ } else {
158
+ hasExistingIntegration = false;
159
+ if (connectMessage) connectMessage.textContent = modal.dataset.loginRequired;
160
+ }
161
+ updateStep();
162
+ })
163
+ .catch(error => {
164
+ console.error('Error loading integration status:', error);
165
+ statusEl.textContent = '';
166
+ showError('Failed to load integration status');
167
+ });
168
+ }
169
+
170
+ function showExistingIntegration(linkedPages) {
171
+ if (!existingContainer || !existingList) return;
172
+
173
+ existingList.innerHTML = '';
174
+ linkedPages.forEach(function (page) {
175
+ const li = document.createElement('li');
176
+ const link = document.createElement('a');
177
+ link.href = page.page_url;
178
+ link.target = '_blank';
179
+ link.textContent = page.page_title || 'Untitled Page';
180
+ li.appendChild(link);
181
+
182
+ if (page.last_synced_at) {
183
+ const syncInfo = document.createElement('span');
184
+ syncInfo.textContent = ` (synced ${new Date(page.last_synced_at).toLocaleDateString()})`;
185
+ syncInfo.style.color = 'var(--color-text-secondary)';
186
+ li.appendChild(syncInfo);
187
+ }
188
+
189
+ existingList.appendChild(li);
190
+ });
191
+
192
+ if (connectMessage) connectMessage.style.display = 'none';
193
+ if (loginBtn) loginBtn.style.display = 'none';
194
+ if (syncBtn) syncBtn.style.display = 'inline-block';
195
+ if (deleteBtn) deleteBtn.style.display = 'inline-block';
196
+ existingContainer.style.display = 'block';
197
+ }
198
+
199
+ function loadAvailablePages() {
200
+ // Pages are now loaded with the initial status call
201
+ return Promise.resolve(availablePages);
202
+ }
203
+
204
+ function updateParentPageOptions() {
205
+ const showParentSelect = document.querySelector('input[name="notion-export-type"]:checked')?.value === 'select-parent';
206
+
207
+ if (parentPageSection) {
208
+ parentPageSection.style.display = showParentSelect ? 'block' : 'none';
209
+ }
210
+
211
+ if (showParentSelect && availablePages.length === 0) {
212
+ loadAvailablePages().then(pages => {
213
+ availablePages = pages;
214
+ updateParentPageSelect();
215
+ });
216
+ }
217
+ }
218
+
219
+ function updateParentPageSelect() {
220
+ if (!parentPageSelect) return;
221
+
222
+ console.log('Updating parent page select with', availablePages.length, 'pages');
223
+ parentPageSelect.innerHTML = '';
224
+
225
+ if (availablePages.length === 0) {
226
+ const option = document.createElement('option');
227
+ option.value = '';
228
+ option.textContent = 'No pages available or loading...';
229
+ parentPageSelect.appendChild(option);
230
+ } else {
231
+ const defaultOption = document.createElement('option');
232
+ defaultOption.value = '';
233
+ defaultOption.textContent = 'Select a parent page';
234
+ parentPageSelect.appendChild(defaultOption);
235
+
236
+ availablePages.forEach(page => {
237
+ console.log('Adding page option:', page);
238
+ const option = document.createElement('option');
239
+ option.value = page.id;
240
+ option.textContent = page.title || 'Untitled';
241
+ parentPageSelect.appendChild(option);
242
+ });
243
+ }
244
+ }
245
+
246
+ function updateSummary() {
247
+ if (creativeTitleEl) {
248
+ // Use the creative title from the API response
249
+ const title = window.notionCreativeTitle || 'Current Creative';
250
+ console.log(`Creative title used: "${title}" for ID: ${creativeId}`);
251
+ creativeTitleEl.textContent = title;
252
+ }
253
+
254
+ if (workspaceSummaryEl && workspaceInfo) {
255
+ workspaceSummaryEl.textContent = workspaceInfo.workspace_name || 'Notion Workspace';
256
+ }
257
+
258
+ const selectedExportType = document.querySelector('input[name="notion-export-type"]:checked')?.value || 'new-page';
259
+ exportType = selectedExportType;
260
+
261
+ if (exportTypeSummaryEl) {
262
+ exportTypeSummaryEl.textContent = selectedExportType === 'new-page' ? 'New page' : 'Subpage';
263
+ }
264
+
265
+ if (selectedExportType === 'select-parent') {
266
+ selectedParentPage = parentPageSelect?.value || null;
267
+ if (parentSummaryEl && parentPageSummaryEl) {
268
+ if (selectedParentPage) {
269
+ const selectedPage = availablePages.find(p => p.id === selectedParentPage);
270
+ parentPageSummaryEl.textContent = selectedPage?.title || 'Selected page';
271
+ parentSummaryEl.style.display = 'block';
272
+ } else {
273
+ parentSummaryEl.style.display = 'none';
274
+ }
275
+ }
276
+ } else {
277
+ if (parentSummaryEl) parentSummaryEl.style.display = 'none';
278
+ }
279
+ }
280
+
281
+ function performExport() {
282
+ if (!creativeId) {
283
+ showError(modal.dataset.noCreative);
284
+ return;
285
+ }
286
+
287
+ exportBtn.disabled = true;
288
+ exportBtn.textContent = 'Exporting...';
289
+ clearError();
290
+
291
+ const requestData = {
292
+ action: 'export'
293
+ };
294
+
295
+ if (exportType === 'select-parent' && selectedParentPage) {
296
+ requestData.parent_page_id = selectedParentPage;
297
+ }
298
+
299
+ console.log('Sending export request:', requestData);
300
+ console.log('Export type:', exportType, 'Selected parent page:', selectedParentPage);
301
+
302
+ fetch(`/notion/creatives/${creativeId}/notion_integration`, {
303
+ method: 'PATCH',
304
+ headers: {
305
+ 'X-CSRF-Token': csrfToken(),
306
+ 'Content-Type': 'application/json'
307
+ },
308
+ body: JSON.stringify(requestData)
309
+ })
310
+ .then(response => response.json())
311
+ .then(data => {
312
+ if (data.success) {
313
+ statusEl.textContent = modal.dataset.exportSuccess || 'Export started successfully';
314
+ statusEl.style.color = 'green';
315
+ setTimeout(() => {
316
+ modal.style.display = 'none';
317
+ resetWizard();
318
+ }, 2000);
319
+ } else {
320
+ showError(data.message || 'Export failed');
321
+ }
322
+ })
323
+ .catch(error => {
324
+ console.error('Export error:', error);
325
+ showError('Export failed');
326
+ })
327
+ .finally(() => {
328
+ exportBtn.disabled = false;
329
+ exportBtn.textContent = 'Export to Notion';
330
+ });
331
+ }
332
+
333
+ function performSync() {
334
+ if (!creativeId) return;
335
+
336
+ syncBtn.disabled = true;
337
+ syncBtn.textContent = 'Syncing...';
338
+ clearError();
339
+
340
+ fetch(`/notion/creatives/${creativeId}/notion_integration`, {
341
+ method: 'PATCH',
342
+ headers: {
343
+ 'X-CSRF-Token': csrfToken(),
344
+ 'Content-Type': 'application/json'
345
+ },
346
+ body: JSON.stringify({ action: 'sync' })
347
+ })
348
+ .then(response => response.json())
349
+ .then(data => {
350
+ if (data.success) {
351
+ statusEl.textContent = modal.dataset.syncSuccess || 'Sync completed successfully';
352
+ statusEl.style.color = 'green';
353
+ } else {
354
+ showError(data.message || 'Sync failed');
355
+ }
356
+ })
357
+ .catch(error => {
358
+ console.error('Sync error:', error);
359
+ showError('Sync failed');
360
+ })
361
+ .finally(() => {
362
+ syncBtn.disabled = false;
363
+ syncBtn.textContent = 'Sync to Notion';
364
+ });
365
+ }
366
+
367
+ function performDelete() {
368
+ if (!confirm(modal.dataset.deleteConfirm)) return;
369
+
370
+ deleteBtn.disabled = true;
371
+ deleteBtn.textContent = 'Removing...';
372
+ clearError();
373
+
374
+ fetch(`/notion/creatives/${creativeId}/notion_integration`, {
375
+ method: 'DELETE',
376
+ headers: {
377
+ 'X-CSRF-Token': csrfToken(),
378
+ 'Content-Type': 'application/json'
379
+ }
380
+ })
381
+ .then(response => response.json())
382
+ .then(data => {
383
+ if (data.success) {
384
+ statusEl.textContent = modal.dataset.deleteSuccess || 'Integration removed successfully';
385
+ statusEl.style.color = 'green';
386
+ setTimeout(() => {
387
+ modal.style.display = 'none';
388
+ resetWizard();
389
+ }, 2000);
390
+ } else {
391
+ showError(data.message || 'Deletion failed');
392
+ }
393
+ })
394
+ .catch(error => {
395
+ console.error('Delete error:', error);
396
+ showError('Deletion failed');
397
+ })
398
+ .finally(() => {
399
+ deleteBtn.disabled = false;
400
+ deleteBtn.textContent = 'Remove link';
401
+ });
402
+ }
403
+
404
+ // Event listeners
405
+ openBtn.addEventListener('click', function () {
406
+ creativeId = this.dataset.creativeId;
407
+ if (!creativeId) {
408
+ alert(modal.dataset.noCreative);
409
+ return;
410
+ }
411
+ modal.style.display = 'flex';
412
+ resetWizard();
413
+ loadIntegrationStatus();
414
+ });
415
+
416
+ closeBtn.addEventListener('click', function () {
417
+ modal.style.display = 'none';
418
+ resetWizard();
419
+ });
420
+
421
+ // Listen for OAuth success message from popup window
422
+ window.addEventListener('message', function(event) {
423
+ // Verify origin for security
424
+ if (event.origin !== window.location.origin) return;
425
+
426
+ if (event.data && event.data.type === 'notion_oauth_success') {
427
+ console.log('Received Notion OAuth success message');
428
+ loadIntegrationStatus();
429
+ }
430
+ });
431
+
432
+ loginBtn.addEventListener('click', function () {
433
+ console.log('Notion login button clicked');
434
+ const width = parseInt(this.dataset.windowWidth) || 600;
435
+ const height = parseInt(this.dataset.windowHeight) || 700;
436
+ const left = (screen.width - width) / 2;
437
+ const top = (screen.height - height) / 2;
438
+
439
+ const authWindow = window.open('', 'notion-auth-window',
440
+ `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`);
441
+
442
+ if (authWindow) {
443
+ loginForm.target = 'notion-auth-window';
444
+ loginForm.submit();
445
+ console.log('Auth form submitted to popup window');
446
+
447
+ const checkClosed = setInterval(() => {
448
+ if (authWindow.closed) {
449
+ clearInterval(checkClosed);
450
+ console.log('Auth window closed, reloading integration status');
451
+ setTimeout(() => loadIntegrationStatus(), 1000);
452
+ }
453
+ }, 1000);
454
+ } else {
455
+ loginForm.target = '_blank';
456
+ loginForm.submit();
457
+ }
458
+ });
459
+
460
+ prevBtn.addEventListener('click', function () {
461
+ if (currentStep === 'workspace') {
462
+ currentStep = 'connect';
463
+ } else if (currentStep === 'summary') {
464
+ currentStep = 'workspace';
465
+ }
466
+ updateStep();
467
+ });
468
+
469
+ nextBtn.addEventListener('click', function () {
470
+ clearError();
471
+ if (currentStep === 'connect') {
472
+ currentStep = 'workspace';
473
+ updateParentPageOptions();
474
+ } else if (currentStep === 'workspace') {
475
+ updateSummary();
476
+ currentStep = 'summary';
477
+ }
478
+ updateStep();
479
+ });
480
+
481
+ exportBtn.addEventListener('click', performExport);
482
+ if (syncBtn) syncBtn.addEventListener('click', performSync);
483
+ if (deleteBtn) deleteBtn.addEventListener('click', performDelete);
484
+
485
+ // Listen for export type changes
486
+ document.addEventListener('change', function (e) {
487
+ if (e.target.name === 'notion-export-type') {
488
+ updateParentPageOptions();
489
+ }
490
+ });
491
+
492
+ // Listen for parent page selection
493
+ if (parentPageSelect) {
494
+ parentPageSelect.addEventListener('change', function () {
495
+ selectedParentPage = this.value;
496
+ });
497
+ }
498
+
499
+ modal.addEventListener('click', function (e) {
500
+ if (e.target === modal) {
501
+ modal.style.display = 'none';
502
+ resetWizard();
503
+ }
504
+ });
505
+ });
506
+ }
@@ -0,0 +1,29 @@
1
+ module CollavreNotion
2
+ class NotionExportJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(creative, notion_account, parent_page_id = nil)
6
+ service = CollavreNotion::NotionService.new(user: notion_account.user)
7
+
8
+ begin
9
+ # Export the creative tree to Notion
10
+ link = service.sync_creative(creative, parent_page_id: parent_page_id)
11
+
12
+ Rails.logger.info("Successfully exported creative #{creative.id} to Notion page #{link.page_id}")
13
+
14
+ # You could add broadcast/notification logic here if needed
15
+ # ActionCable.server.broadcast("user_#{notion_account.user.id}", {
16
+ # type: 'notion_export_complete',
17
+ # creative_id: creative.id,
18
+ # page_url: link.page_url
19
+ # })
20
+ rescue NotionError => e
21
+ Rails.logger.error("Notion export failed for creative #{creative.id}: #{e.message}")
22
+ raise e
23
+ rescue StandardError => e
24
+ Rails.logger.error("Unexpected error during Notion export for creative #{creative.id}: #{e.message}")
25
+ raise e
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ module CollavreNotion
2
+ class NotionSyncJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(creative, notion_account, page_id)
6
+ service = CollavreNotion::NotionService.new(user: notion_account.user)
7
+
8
+ begin
9
+ # Find the existing link
10
+ link = CollavreNotion::NotionPageLink.find_by(
11
+ creative: creative,
12
+ notion_account: notion_account,
13
+ page_id: page_id
14
+ )
15
+
16
+ unless link
17
+ Rails.logger.error("No Notion page link found for creative #{creative.id} and page #{page_id}")
18
+ return
19
+ end
20
+
21
+ # Update the existing Notion page with children as blocks
22
+ title = ActionController::Base.helpers.strip_tags(creative.description).strip.presence || "Untitled Creative"
23
+ children = creative.children.to_a
24
+ Rails.logger.info("NotionSyncJob: Syncing creative #{creative.id} as page title with #{children.count} children as blocks")
25
+ blocks = children.any? ? NotionCreativeExporter.new(creative).export_tree_blocks(children, 1, 0) : []
26
+
27
+ properties = {
28
+ title: {
29
+ title: [ { text: { content: title } } ]
30
+ }
31
+ }
32
+
33
+ service.update_page(page_id, properties: properties, blocks: blocks)
34
+ link.update!(page_title: title)
35
+ link.mark_synced!
36
+
37
+ Rails.logger.info("Successfully synced creative #{creative.id} to Notion page #{page_id}")
38
+ rescue NotionError => e
39
+ Rails.logger.error("Notion sync failed for creative #{creative.id}: #{e.message}")
40
+ raise e
41
+ rescue StandardError => e
42
+ Rails.logger.error("Unexpected error during Notion sync for creative #{creative.id}: #{e.message}")
43
+ raise e
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,17 @@
1
+ module CollavreNotion
2
+ class NotionAccount < ApplicationRecord
3
+ self.table_name = "notion_accounts"
4
+
5
+ belongs_to :user, class_name: "::User"
6
+ has_many :notion_page_links, class_name: "CollavreNotion::NotionPageLink", dependent: :destroy
7
+
8
+ encrypts :token, deterministic: false
9
+
10
+ validates :notion_uid, :token, presence: true
11
+ validates :notion_uid, uniqueness: true
12
+
13
+ def expired?
14
+ token_expires_at.present? && token_expires_at < Time.current
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,10 @@
1
+ module CollavreNotion
2
+ class NotionBlockLink < ApplicationRecord
3
+ self.table_name = "notion_block_links"
4
+
5
+ belongs_to :notion_page_link, class_name: "CollavreNotion::NotionPageLink"
6
+ belongs_to :creative, class_name: "Collavre::Creative"
7
+
8
+ validates :block_id, presence: true, uniqueness: { scope: :notion_page_link_id }
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ module CollavreNotion
2
+ class NotionPageLink < ApplicationRecord
3
+ self.table_name = "notion_page_links"
4
+
5
+ belongs_to :creative, class_name: "Collavre::Creative"
6
+ belongs_to :notion_account, class_name: "CollavreNotion::NotionAccount"
7
+ has_many :notion_block_links, class_name: "CollavreNotion::NotionBlockLink", dependent: :destroy
8
+
9
+ validates :page_id, :page_title, presence: true
10
+ validates :page_id, uniqueness: true
11
+
12
+ scope :recent, -> { order(last_synced_at: :desc) }
13
+ scope :synced, -> { where.not(last_synced_at: nil) }
14
+
15
+ def mark_synced!
16
+ touch(:last_synced_at)
17
+ end
18
+ end
19
+ end