collavre 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -1,48 +0,0 @@
1
- module Collavre
2
- class NotionSyncJob < ApplicationJob
3
- queue_as :default
4
-
5
- def perform(creative, notion_account, page_id)
6
- service = NotionService.new(user: notion_account.user)
7
-
8
- begin
9
- # Find the existing link
10
- link = 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
-
39
- rescue NotionError => e
40
- Rails.logger.error("Notion sync failed for creative #{creative.id}: #{e.message}")
41
- raise e
42
- rescue StandardError => e
43
- Rails.logger.error("Unexpected error during Notion sync for creative #{creative.id}: #{e.message}")
44
- raise e
45
- end
46
- end
47
- end
48
- end
@@ -1,17 +0,0 @@
1
- module Collavre
2
- class NotionAccount < ApplicationRecord
3
- self.table_name = "notion_accounts"
4
-
5
- belongs_to :user, class_name: Collavre.configuration.user_class_name
6
- has_many :notion_page_links, class_name: "Collavre::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
@@ -1,10 +0,0 @@
1
- module Collavre
2
- class NotionBlockLink < ApplicationRecord
3
- self.table_name = "notion_block_links"
4
-
5
- belongs_to :notion_page_link, class_name: "Collavre::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
@@ -1,19 +0,0 @@
1
- module Collavre
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: "Collavre::NotionAccount"
7
- has_many :notion_block_links, class_name: "Collavre::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
@@ -1,231 +0,0 @@
1
- module Collavre
2
- class NotionClient
3
- BASE_URL = "https://api.notion.com/v1"
4
- API_VERSION = "2022-06-28"
5
-
6
- def initialize(account)
7
- @account = account
8
- @token = account.token
9
- end
10
-
11
- def search_pages(query: nil, start_cursor: nil, page_size: 10)
12
- Rails.logger.info("NotionClient: Searching for pages with query: #{query}, page_size: #{page_size}")
13
-
14
- body = {
15
- filter: { property: "object", value: "page" },
16
- page_size: page_size
17
- }
18
- body[:query] = query if query.present?
19
- body[:start_cursor] = start_cursor if start_cursor.present?
20
-
21
- Rails.logger.info("NotionClient: Search request body: #{body.to_json}")
22
- result = post("search", body)
23
- Rails.logger.info("NotionClient: Search returned #{result["results"]&.length || 0} results")
24
- result
25
- end
26
-
27
- def get_page(page_id)
28
- get("pages/#{format_id(page_id)}")
29
- end
30
-
31
- def create_page(parent_id:, title:, blocks: [])
32
- Rails.logger.info("NotionClient: Creating page with #{blocks.length} blocks")
33
-
34
- # Notion limits page creation to 100 blocks, so create with minimal content first
35
- body = {
36
- parent: { page_id: parent_id },
37
- properties: {
38
- title: {
39
- title: [ { text: { content: title } } ]
40
- }
41
- },
42
- children: blocks.length > 100 ? [] : blocks
43
- }
44
-
45
- response = post("pages", body)
46
-
47
- # If we have more than 100 blocks, add them in batches after page creation
48
- if blocks.length > 100
49
- Rails.logger.info("NotionClient: Adding #{blocks.length} blocks in batches (100 per batch)")
50
- page_id = response["id"]
51
- Rails.logger.info("NotionClient: Created page ID: #{page_id}")
52
-
53
- # Give Notion a moment to fully create the page
54
- sleep(1)
55
-
56
- # Add blocks in batches of 100
57
- blocks.each_slice(100).with_index do |block_batch, index|
58
- Rails.logger.info("NotionClient: Adding batch #{index + 1} with #{block_batch.length} blocks")
59
- append_blocks(page_id, block_batch)
60
- Rails.logger.info("NotionClient: Successfully added batch #{index + 1}")
61
- end
62
- end
63
-
64
- response
65
- end
66
-
67
- def update_page(page_id, properties: {}, blocks: nil)
68
- body = { properties: properties }
69
- response = patch("pages/#{format_id(page_id)}", body)
70
-
71
- if blocks.present?
72
- replace_page_blocks(page_id, blocks)
73
- end
74
-
75
- response
76
- end
77
-
78
- def get_page_blocks(page_id, start_cursor: nil, page_size: 100)
79
- params = { page_size: page_size }
80
- params[:start_cursor] = start_cursor if start_cursor.present?
81
-
82
- get("blocks/#{format_id(page_id)}/children", params)
83
- end
84
-
85
- def replace_page_blocks(page_id, blocks)
86
- Rails.logger.info("NotionClient: Replacing page blocks with #{blocks.length} blocks")
87
-
88
- # First, get existing blocks
89
- existing_blocks = get_page_blocks(page_id)
90
-
91
- # Delete existing blocks
92
- existing_blocks.dig("results")&.each do |block|
93
- delete_block(block["id"])
94
- end
95
-
96
- # Add new blocks in batches of 100
97
- if blocks.any?
98
- blocks.each_slice(100).with_index do |block_batch, index|
99
- Rails.logger.info("NotionClient: Adding replacement batch #{index + 1} with #{block_batch.length} blocks")
100
- append_blocks(page_id, block_batch)
101
- end
102
- end
103
- end
104
-
105
- def append_blocks(page_id, blocks)
106
- # Notion also limits append operations to 100 blocks
107
- if blocks.length > 100
108
- Rails.logger.info("NotionClient: Appending #{blocks.length} blocks in batches")
109
- aggregated_results = []
110
-
111
- blocks.each_slice(100).with_index do |block_batch, index|
112
- Rails.logger.info("NotionClient: Appending batch #{index + 1} with #{block_batch.length} blocks")
113
- response = patch("blocks/#{format_id(page_id)}/children", { children: block_batch })
114
-
115
- if response.is_a?(Hash)
116
- batch_results = response.fetch("results", [])
117
- aggregated_results.concat(batch_results) if batch_results.any?
118
- end
119
- end
120
-
121
- aggregated_results.any? ? { "results" => aggregated_results } : nil
122
- else
123
- patch("blocks/#{format_id(page_id)}/children", { children: blocks })
124
- end
125
- end
126
-
127
- def delete_block(block_id)
128
- delete("blocks/#{format_id(block_id)}")
129
- end
130
-
131
- def get_workspace
132
- get("users/me")
133
- end
134
-
135
- private
136
-
137
- def get(path, params = {})
138
- url = "#{BASE_URL}/#{path}"
139
- url += "?#{params.to_query}" if params.any?
140
-
141
- response = HTTParty.get(
142
- url,
143
- headers: headers,
144
- timeout: 30
145
- )
146
-
147
- handle_response(response)
148
- end
149
-
150
- def post(path, body)
151
- response = HTTParty.post(
152
- "#{BASE_URL}/#{path}",
153
- headers: headers,
154
- body: body.to_json,
155
- timeout: 30
156
- )
157
-
158
- handle_response(response)
159
- end
160
-
161
- def patch(path, body)
162
- response = HTTParty.patch(
163
- "#{BASE_URL}/#{path}",
164
- headers: headers,
165
- body: body.to_json,
166
- timeout: 30
167
- )
168
-
169
- handle_response(response)
170
- end
171
-
172
- def delete(path)
173
- response = HTTParty.delete(
174
- "#{BASE_URL}/#{path}",
175
- headers: headers,
176
- timeout: 30
177
- )
178
-
179
- handle_response(response)
180
- end
181
-
182
- def headers
183
- {
184
- "Authorization" => "Bearer #{@token}",
185
- "Notion-Version" => API_VERSION,
186
- "Content-Type" => "application/json"
187
- }
188
- end
189
-
190
- def format_id(id)
191
- # Keep dashes in UUIDs - Notion API expects them
192
- id.to_s
193
- end
194
-
195
- def handle_response(response)
196
- Rails.logger.info("Notion API Response: #{response.code} for #{response.request.last_uri}")
197
- Rails.logger.debug("Notion API Response Body: #{response.body}")
198
-
199
- case response.code
200
- when 200, 201
201
- response.parsed_response
202
- when 400
203
- Rails.logger.error("Notion API 400 error: #{response.body}")
204
- raise NotionError, "Bad request: #{response.parsed_response}"
205
- when 401
206
- Rails.logger.error("Notion API 401 error: #{response.body}")
207
- raise NotionAuthError, "Unauthorized: Token may be expired"
208
- when 403
209
- Rails.logger.error("Notion API 403 error: #{response.body}")
210
- raise NotionError, "Forbidden: Insufficient permissions"
211
- when 404
212
- Rails.logger.error("Notion API 404 error: #{response.body}")
213
- raise NotionError, "Resource not found"
214
- when 429
215
- Rails.logger.error("Notion API 429 error: #{response.body}")
216
- raise NotionRateLimitError, "Rate limit exceeded"
217
- else
218
- Rails.logger.error("Notion API error: #{response.code} #{response.body}")
219
- raise NotionError, "API error: #{response.code}"
220
- end
221
- rescue HTTParty::Error, SocketError, Timeout::Error => e
222
- Rails.logger.error("Notion API connection error: #{e.message}")
223
- raise NotionConnectionError, "Connection failed: #{e.message}"
224
- end
225
- end
226
-
227
- class NotionError < StandardError; end
228
- class NotionAuthError < NotionError; end
229
- class NotionRateLimitError < NotionError; end
230
- class NotionConnectionError < NotionError; end
231
- end
@@ -1,296 +0,0 @@
1
- module Collavre
2
- class NotionCreativeExporter
3
- include CreativesHelper
4
-
5
- def initialize(creative, with_progress: false)
6
- @creative = creative
7
- @with_progress = with_progress
8
- end
9
-
10
- def export_blocks
11
- convert_creative_to_blocks(@creative, level: 1)
12
- end
13
-
14
- def export_tree_blocks(creatives, level = 1, bullet_depth = 0)
15
- return [] if creatives.blank?
16
-
17
- blocks = []
18
- creatives.each do |creative|
19
- # Convert the creative to blocks
20
- creative_blocks = convert_creative_to_blocks(creative, level: level)
21
-
22
- # Handle children based on the level
23
- if creative.respond_to?(:children) && creative.children.present?
24
- if level > 3
25
- # For bullet points (level > 3), limit nesting depth to 2 levels max
26
- text_content = extract_text_content(creative.effective_description(nil, true).gsub(/<!--.*?-->/m, "").strip)
27
-
28
- if bullet_depth < 2
29
- # Can still nest deeper
30
- children_blocks = export_tree_blocks(creative.children, level + 1, bullet_depth + 1)
31
- bullet_block = create_bulleted_list_item_block(text_content, children_blocks)
32
- blocks << bullet_block
33
- else
34
- # Max depth reached, flatten remaining levels
35
- bullet_block = create_bulleted_list_item_block(text_content)
36
- blocks << bullet_block
37
- # Add children as flat bullet points at same level
38
- blocks.concat(export_tree_blocks(creative.children, level, bullet_depth))
39
- end
40
- else
41
- # For headings (level <= 3), add heading then children as separate blocks
42
- blocks.concat(creative_blocks)
43
- blocks.concat(export_tree_blocks(creative.children, level + 1, 0))
44
- end
45
- else
46
- # No children, just add the blocks
47
- blocks.concat(creative_blocks)
48
- end
49
- end
50
-
51
- blocks
52
- end
53
-
54
- private
55
-
56
- def convert_creative_to_blocks(creative, level: 1)
57
- blocks = []
58
- description_content = creative.effective_description(nil, true)
59
- desc = description_content.present? ? description_content.to_s : ""
60
-
61
- # Add progress if requested and available
62
- if @with_progress && creative.respond_to?(:progress) && !creative.progress.nil?
63
- pct = (creative.progress.to_f * 100).round
64
- desc = "#{desc} (#{pct}%)"
65
- end
66
-
67
- # Clean HTML and prepare content
68
- raw_html = desc.gsub(/<!--.*?-->/m, "").strip
69
-
70
- # Handle different content types
71
- if level <= 3 && contains_table?(raw_html)
72
- blocks.concat(convert_table_to_blocks(raw_html))
73
- elsif level <= 3
74
- # Use as heading
75
- text_content = extract_text_content(raw_html)
76
- if text_content.present?
77
- blocks << create_heading_block(text_content, level)
78
- end
79
- else
80
- # Use as bulleted list item for deeper levels
81
- text_content = extract_text_content(raw_html)
82
- if text_content.present?
83
- blocks << create_bulleted_list_item_block(text_content)
84
- end
85
- end
86
-
87
- # Handle rich content like images and links within the HTML
88
- blocks.concat(convert_rich_content_to_blocks(raw_html))
89
-
90
- blocks
91
- end
92
-
93
- def contains_table?(html)
94
- html.match?(%r{<table\b[^>]*>.*?</table>}im) ||
95
- html.match?(/^\s*\|.*?\|(?:\s*\n\s*\|.*?\|)*\s*$/m)
96
- end
97
-
98
- def convert_table_to_blocks(html)
99
- blocks = []
100
-
101
- # Extract table content
102
- table_match = html.match(%r{<table\b[^>]*>(.*?)</table>}im)
103
- if table_match
104
- table_html = table_match[1]
105
- table_data = parse_html_table(table_html)
106
- if table_data.any?
107
- blocks << create_table_block(table_data)
108
- end
109
- else
110
- # Try markdown table format
111
- markdown_table = extract_markdown_table(html)
112
- if markdown_table
113
- table_data = parse_markdown_table(markdown_table)
114
- if table_data.any?
115
- blocks << create_table_block(table_data)
116
- end
117
- end
118
- end
119
-
120
- blocks
121
- end
122
-
123
- def parse_html_table(table_html)
124
- fragment = Nokogiri::HTML::DocumentFragment.parse("<table>#{table_html}</table>")
125
- table = fragment.at_css("table")
126
- return [] unless table
127
-
128
- rows = []
129
- table.css("tr").each do |row|
130
- cells = row.css("th,td").map do |cell|
131
- text = extract_text_content(cell.inner_html)
132
- create_table_cell_content(text)
133
- end
134
- rows << cells if cells.any?
135
- end
136
-
137
- rows
138
- end
139
-
140
- def parse_markdown_table(table_text)
141
- lines = table_text.strip.split("\n").map(&:strip)
142
- return [] if lines.length < 2
143
-
144
- rows = []
145
- lines.each_with_index do |line, index|
146
- next if index == 1 # Skip alignment row
147
-
148
- cells = line.split("|").map(&:strip).reject(&:empty?)
149
- next if cells.empty?
150
-
151
- cell_contents = cells.map { |cell| create_table_cell_content(cell.strip) }
152
- rows << cell_contents
153
- end
154
-
155
- rows
156
- end
157
-
158
- def extract_markdown_table(html)
159
- # Look for markdown table patterns in the HTML
160
- html.match(/(\|.*?\|(?:\s*\n\s*\|.*?\|)*)/m)&.captures&.first
161
- end
162
-
163
- def convert_rich_content_to_blocks(html)
164
- blocks = []
165
-
166
- # Extract images
167
- html.scan(%r{<action-text-attachment ([^>]+)>(?:</action-text-attachment>)?}) do |match|
168
- attrs = Hash[match[0].scan(/(\S+?)="([^"]*)"/)]
169
- sgid = attrs["sgid"]
170
- caption = attrs["caption"] || ""
171
-
172
- if (blob = GlobalID::Locator.locate_signed(sgid, for: "attachable"))
173
- # For now, we'll create a paragraph with the image description
174
- # In a full implementation, you'd upload to Notion's file storage
175
- blocks << create_paragraph_block("📷 #{caption.presence || 'Image attachment'}")
176
- end
177
- end
178
-
179
- # Extract data URLs for images
180
- html.scan(/<img [^>]*src=['"](data:[^'"]+)['"][^>]*alt=['"]([^'"]*)['"][^>]*>/) do |data_url, alt_text|
181
- blocks << create_paragraph_block("📷 #{alt_text.presence || 'Image'}")
182
- end
183
-
184
- blocks
185
- end
186
-
187
- def extract_text_content(html)
188
- # Remove HTML tags and get plain text
189
- ActionView::Base.full_sanitizer.sanitize(html).strip
190
- end
191
-
192
- def create_heading_block(text, level)
193
- # Notion supports heading_1, heading_2, heading_3
194
- heading_type = case level
195
- when 1 then "heading_1"
196
- when 2 then "heading_2"
197
- else "heading_3"
198
- end
199
-
200
- heading_key = heading_type.to_sym
201
-
202
- {
203
- object: "block",
204
- type: heading_type,
205
- heading_key => {
206
- rich_text: [ create_rich_text(text) ]
207
- }
208
- }
209
- end
210
-
211
- def create_paragraph_block(text)
212
- {
213
- object: "block",
214
- type: "paragraph",
215
- paragraph: {
216
- rich_text: [ create_rich_text(text) ]
217
- }
218
- }
219
- end
220
-
221
- def create_bulleted_list_item_block(text, children_blocks = [])
222
- block = {
223
- object: "block",
224
- type: "bulleted_list_item",
225
- bulleted_list_item: {
226
- rich_text: [ create_rich_text(text) ]
227
- }
228
- }
229
-
230
- # Add nested children if present
231
- if children_blocks.any?
232
- block[:bulleted_list_item][:children] = children_blocks
233
- end
234
-
235
- block
236
- end
237
-
238
- def create_table_block(table_data)
239
- return nil if table_data.empty?
240
-
241
- # Notion tables need consistent column count
242
- max_columns = table_data.map(&:length).max
243
- normalized_rows = table_data.map do |row|
244
- row + Array.new([ max_columns - row.length, 0 ].max) { create_table_cell_content("") }
245
- end
246
-
247
- {
248
- object: "block",
249
- type: "table",
250
- table: {
251
- table_width: max_columns,
252
- has_column_header: true,
253
- has_row_header: false,
254
- children: normalized_rows.map do |row_data|
255
- {
256
- object: "block",
257
- type: "table_row",
258
- table_row: {
259
- cells: row_data
260
- }
261
- }
262
- end
263
- }
264
- }
265
- end
266
-
267
- def create_table_cell_content(text)
268
- [ create_rich_text(text.to_s) ]
269
- end
270
-
271
- def create_rich_text(text)
272
- # Notion has a 2000 character limit per text block
273
- content = text.to_s.strip
274
- if content.length > 1990 # Be conservative to account for any extra characters
275
- original_length = content.length
276
- content = content[0..1986] + "..." # 1987 + 3 = 1990 chars
277
- Rails.logger.warn("NotionCreativeExporter: Truncated content from #{original_length} to #{content.length} characters")
278
- end
279
-
280
- {
281
- type: "text",
282
- text: {
283
- content: content
284
- },
285
- annotations: {
286
- bold: false,
287
- italic: false,
288
- strikethrough: false,
289
- underline: false,
290
- code: false,
291
- color: "default"
292
- }
293
- }
294
- end
295
- end
296
- end