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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
- data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
- data/app/assets/stylesheets/collavre/popup.css +7 -0
- data/app/assets/stylesheets/collavre/print.css +18 -0
- data/app/channels/collavre/comments_presence_channel.rb +33 -0
- data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
- data/app/components/collavre/autocomplete_popup_component.rb +18 -0
- data/app/components/collavre/command_menu_component.rb +7 -0
- data/app/components/collavre/plans_timeline_component.html.erb +1 -1
- data/app/components/collavre/plans_timeline_component.rb +29 -32
- data/app/components/collavre/user_mention_menu_component.rb +4 -5
- data/app/controllers/collavre/comments_controller.rb +111 -10
- data/app/controllers/collavre/creatives_controller.rb +8 -0
- data/app/controllers/collavre/google_auth_controller.rb +5 -1
- data/app/controllers/collavre/plans_controller.rb +65 -9
- data/app/controllers/collavre/topics_controller.rb +42 -0
- data/app/controllers/collavre/users_controller.rb +4 -14
- data/app/errors/collavre/approval_pending_error.rb +54 -0
- data/app/errors/collavre/cancelled_error.rb +9 -0
- data/app/helpers/collavre/navigation_helper.rb +3 -1
- data/app/javascript/collavre.js +1 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
- data/app/javascript/controllers/comments/form_controller.js +2 -1
- data/app/javascript/controllers/comments/list_controller.js +185 -2
- data/app/javascript/controllers/comments/popup_controller.js +95 -20
- data/app/javascript/controllers/comments/presence_controller.js +30 -1
- data/app/javascript/controllers/comments/topics_controller.js +314 -4
- data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
- data/app/javascript/modules/command_menu.js +116 -0
- data/app/javascript/modules/creative_progress.js +14 -0
- data/app/javascript/modules/creative_row_editor.js +104 -20
- data/app/javascript/modules/plans_timeline.js +15 -4
- data/app/javascript/modules/share_modal.js +3 -0
- data/app/jobs/collavre/ai_agent_job.rb +35 -21
- data/app/models/collavre/calendar_event.rb +7 -1
- data/app/models/collavre/comment.rb +35 -2
- data/app/models/collavre/creative.rb +1 -3
- data/app/models/collavre/mcp_tool.rb +4 -0
- data/app/models/collavre/plan.rb +23 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +15 -1
- data/app/services/collavre/ai_agent_service.rb +174 -66
- data/app/services/collavre/ai_client.rb +31 -2
- data/app/services/collavre/comments/action_executor.rb +47 -1
- data/app/services/collavre/comments/calendar_command.rb +117 -18
- data/app/services/collavre/google_calendar_service.rb +38 -15
- data/app/services/collavre/markdown_importer.rb +47 -8
- data/app/services/collavre/mcp_service.rb +23 -10
- data/app/services/collavre/system_events/router.rb +50 -26
- data/app/services/collavre/tools/creative_create_service.rb +97 -0
- data/app/services/collavre/tools/creative_update_service.rb +116 -0
- data/app/views/collavre/comments/_comment.html.erb +2 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
- data/app/views/collavre/comments/fullscreen.html.erb +5 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
- data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
- data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
- data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
- data/app/views/collavre/creatives/_share_button.html.erb +1 -1
- data/app/views/collavre/creatives/index.html.erb +22 -4
- data/app/views/collavre/users/edit_ai.html.erb +15 -0
- data/app/views/collavre/users/new_ai.html.erb +15 -0
- data/app/views/layouts/collavre/chat.html.erb +46 -0
- data/config/locales/ai_agent.en.yml +15 -0
- data/config/locales/ai_agent.ko.yml +15 -0
- data/config/locales/comments.en.yml +15 -3
- data/config/locales/comments.ko.yml +15 -3
- data/config/locales/creatives.en.yml +3 -31
- data/config/locales/creatives.ko.yml +3 -27
- data/config/locales/plans.en.yml +4 -0
- data/config/locales/plans.ko.yml +4 -0
- data/config/locales/users.en.yml +3 -0
- data/config/locales/users.ko.yml +3 -0
- data/config/routes.rb +8 -3
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
- data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
- data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
- data/lib/collavre/engine.rb +171 -6
- data/lib/collavre/integration_registry.rb +129 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +2 -0
- data/lib/navigation/registry.rb +130 -0
- metadata +22 -15
- data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
- data/app/controllers/collavre/notion_auth_controller.rb +0 -25
- data/app/jobs/collavre/notion_export_job.rb +0 -30
- data/app/jobs/collavre/notion_sync_job.rb +0 -48
- data/app/models/collavre/notion_account.rb +0 -17
- data/app/models/collavre/notion_block_link.rb +0 -10
- data/app/models/collavre/notion_page_link.rb +0 -19
- data/app/services/collavre/notion_client.rb +0 -231
- data/app/services/collavre/notion_creative_exporter.rb +0 -296
- data/app/services/collavre/notion_service.rb +0 -249
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
- data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
- data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
- 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
|