collavre 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.
Files changed (410) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +221 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/stylesheets/collavre/actiontext.css +577 -0
  5. data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
  7. data/app/assets/stylesheets/collavre/creatives.css +559 -0
  8. data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
  9. data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
  10. data/app/assets/stylesheets/collavre/popup.css +160 -0
  11. data/app/assets/stylesheets/collavre/print.css +37 -0
  12. data/app/assets/stylesheets/collavre/slide_view.css +79 -0
  13. data/app/assets/stylesheets/collavre/user_menu.css +34 -0
  14. data/app/channels/collavre/comments_presence_channel.rb +54 -0
  15. data/app/channels/collavre/slide_view_channel.rb +11 -0
  16. data/app/channels/collavre/topics_channel.rb +12 -0
  17. data/app/components/collavre/avatar_component.html.erb +15 -0
  18. data/app/components/collavre/avatar_component.rb +59 -0
  19. data/app/components/collavre/inbox/badge_component.html.erb +6 -0
  20. data/app/components/collavre/inbox/badge_component.rb +18 -0
  21. data/app/components/collavre/plans_timeline_component.html.erb +14 -0
  22. data/app/components/collavre/plans_timeline_component.rb +56 -0
  23. data/app/components/collavre/popup_menu_component.html.erb +6 -0
  24. data/app/components/collavre/popup_menu_component.rb +30 -0
  25. data/app/components/collavre/progress_filter_component.html.erb +5 -0
  26. data/app/components/collavre/progress_filter_component.rb +10 -0
  27. data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
  28. data/app/components/collavre/user_mention_menu_component.rb +8 -0
  29. data/app/controllers/collavre/application_controller.rb +15 -0
  30. data/app/controllers/collavre/attachments_controller.rb +44 -0
  31. data/app/controllers/collavre/calendar_events_controller.rb +15 -0
  32. data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
  33. data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
  34. data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
  35. data/app/controllers/collavre/comments_controller.rb +464 -0
  36. data/app/controllers/collavre/contacts_controller.rb +10 -0
  37. data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
  38. data/app/controllers/collavre/creative_imports_controller.rb +24 -0
  39. data/app/controllers/collavre/creative_plans_controller.rb +69 -0
  40. data/app/controllers/collavre/creative_shares_controller.rb +79 -0
  41. data/app/controllers/collavre/creatives_controller.rb +535 -0
  42. data/app/controllers/collavre/devices_controller.rb +19 -0
  43. data/app/controllers/collavre/email_verifications_controller.rb +16 -0
  44. data/app/controllers/collavre/emails_controller.rb +11 -0
  45. data/app/controllers/collavre/github_auth_controller.rb +25 -0
  46. data/app/controllers/collavre/google_auth_controller.rb +43 -0
  47. data/app/controllers/collavre/inbox_items_controller.rb +64 -0
  48. data/app/controllers/collavre/invites_controller.rb +27 -0
  49. data/app/controllers/collavre/notion_auth_controller.rb +25 -0
  50. data/app/controllers/collavre/passwords_controller.rb +37 -0
  51. data/app/controllers/collavre/plans_controller.rb +110 -0
  52. data/app/controllers/collavre/sessions_controller.rb +57 -0
  53. data/app/controllers/collavre/topics_controller.rb +58 -0
  54. data/app/controllers/collavre/user_themes_controller.rb +58 -0
  55. data/app/controllers/collavre/users_controller.rb +390 -0
  56. data/app/helpers/collavre/application_helper.rb +4 -0
  57. data/app/helpers/collavre/comments_helper.rb +9 -0
  58. data/app/helpers/collavre/creatives_helper.rb +343 -0
  59. data/app/helpers/collavre/navigation_helper.rb +163 -0
  60. data/app/helpers/collavre/user_themes_helper.rb +4 -0
  61. data/app/javascript/collavre.js +26 -0
  62. data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
  63. data/app/javascript/components/LinkPopup.jsx +112 -0
  64. data/app/javascript/components/creative_tree_row.js +503 -0
  65. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
  66. data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
  67. data/app/javascript/controllers/click_target_controller.js +13 -0
  68. data/app/javascript/controllers/comment_controller.js +162 -0
  69. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
  70. data/app/javascript/controllers/comments/form_controller.js +530 -0
  71. data/app/javascript/controllers/comments/list_controller.js +715 -0
  72. data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
  73. data/app/javascript/controllers/comments/popup_controller.js +385 -0
  74. data/app/javascript/controllers/comments/presence_controller.js +311 -0
  75. data/app/javascript/controllers/comments/topics_controller.js +338 -0
  76. data/app/javascript/controllers/common_popup_controller.js +55 -0
  77. data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
  78. data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
  79. data/app/javascript/controllers/creatives/import_controller.js +116 -0
  80. data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
  81. data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
  82. data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
  83. data/app/javascript/controllers/creatives/tree_controller.js +218 -0
  84. data/app/javascript/controllers/index.js +79 -0
  85. data/app/javascript/controllers/link_creative_controller.js +91 -0
  86. data/app/javascript/controllers/popup_menu_controller.js +82 -0
  87. data/app/javascript/controllers/progress_filter_controller.js +35 -0
  88. data/app/javascript/controllers/reaction_picker_controller.js +107 -0
  89. data/app/javascript/controllers/share_invite_controller.js +15 -0
  90. data/app/javascript/controllers/share_user_search_controller.js +121 -0
  91. data/app/javascript/controllers/tabs_controller.js +43 -0
  92. data/app/javascript/creatives/drag_drop/dom.js +170 -0
  93. data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
  94. data/app/javascript/creatives/drag_drop/indicator.js +35 -0
  95. data/app/javascript/creatives/drag_drop/operations.js +116 -0
  96. data/app/javascript/creatives/drag_drop/state.js +31 -0
  97. data/app/javascript/creatives/tree_renderer.js +248 -0
  98. data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
  99. data/app/javascript/lib/api/creatives.js +79 -0
  100. data/app/javascript/lib/api/csrf_fetch.js +22 -0
  101. data/app/javascript/lib/api/drag_drop.js +31 -0
  102. data/app/javascript/lib/api/queue_manager.js +423 -0
  103. data/app/javascript/lib/apply_lexical_styles.js +15 -0
  104. data/app/javascript/lib/common_popup.js +195 -0
  105. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
  106. data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
  107. data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
  108. data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
  109. data/app/javascript/lib/lexical/attachment_payload.js +293 -0
  110. data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
  111. data/app/javascript/lib/lexical/image_node.jsx +159 -0
  112. data/app/javascript/lib/lexical/style_attributes.js +40 -0
  113. data/app/javascript/lib/responsive_images.js +54 -0
  114. data/app/javascript/lib/turbo_stream_actions.js +33 -0
  115. data/app/javascript/lib/utils/markdown.js +23 -0
  116. data/app/javascript/modules/creative_guide.js +53 -0
  117. data/app/javascript/modules/creative_row_editor.js +1841 -0
  118. data/app/javascript/modules/creative_row_swipe.js +43 -0
  119. data/app/javascript/modules/creatives.js +15 -0
  120. data/app/javascript/modules/export_to_markdown.js +34 -0
  121. data/app/javascript/modules/inbox_panel.js +226 -0
  122. data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
  123. data/app/javascript/modules/mention_menu.js +77 -0
  124. data/app/javascript/modules/plans_menu.js +39 -0
  125. data/app/javascript/modules/plans_timeline.js +397 -0
  126. data/app/javascript/modules/share_modal.js +73 -0
  127. data/app/javascript/modules/share_user_popup.js +77 -0
  128. data/app/javascript/modules/slide_view.js +163 -0
  129. data/app/javascript/services/cable.js +32 -0
  130. data/app/javascript/slide_view.js +2 -0
  131. data/app/javascript/utils/caret_position.js +42 -0
  132. data/app/javascript/utils/clipboard.js +40 -0
  133. data/app/jobs/collavre/ai_agent_job.rb +27 -0
  134. data/app/jobs/collavre/inbox_summary_job.rb +24 -0
  135. data/app/jobs/collavre/notion_export_job.rb +30 -0
  136. data/app/jobs/collavre/notion_sync_job.rb +48 -0
  137. data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
  138. data/app/jobs/collavre/permission_cache_job.rb +71 -0
  139. data/app/jobs/collavre/push_notification_job.rb +86 -0
  140. data/app/mailers/collavre/application_mailer.rb +17 -0
  141. data/app/mailers/collavre/creative_mailer.rb +9 -0
  142. data/app/mailers/collavre/email_verification_mailer.rb +20 -0
  143. data/app/mailers/collavre/inbox_mailer.rb +19 -0
  144. data/app/mailers/collavre/invitation_mailer.rb +16 -0
  145. data/app/mailers/collavre/passwords_mailer.rb +10 -0
  146. data/app/models/collavre/activity_log.rb +13 -0
  147. data/app/models/collavre/application_record.rb +5 -0
  148. data/app/models/collavre/calendar_event.rb +20 -0
  149. data/app/models/collavre/comment.rb +307 -0
  150. data/app/models/collavre/comment_presence_store.rb +30 -0
  151. data/app/models/collavre/comment_reaction.rb +11 -0
  152. data/app/models/collavre/comment_read_pointer.rb +26 -0
  153. data/app/models/collavre/contact.rb +23 -0
  154. data/app/models/collavre/creative.rb +413 -0
  155. data/app/models/collavre/creative_expanded_state.rb +11 -0
  156. data/app/models/collavre/creative_share.rb +122 -0
  157. data/app/models/collavre/creative_shares_cache.rb +18 -0
  158. data/app/models/collavre/current.rb +14 -0
  159. data/app/models/collavre/device.rb +13 -0
  160. data/app/models/collavre/email.rb +14 -0
  161. data/app/models/collavre/github_account.rb +10 -0
  162. data/app/models/collavre/github_repository_link.rb +19 -0
  163. data/app/models/collavre/inbox_item.rb +95 -0
  164. data/app/models/collavre/invitation.rb +22 -0
  165. data/app/models/collavre/label.rb +47 -0
  166. data/app/models/collavre/mcp_tool.rb +30 -0
  167. data/app/models/collavre/notion_account.rb +17 -0
  168. data/app/models/collavre/notion_block_link.rb +10 -0
  169. data/app/models/collavre/notion_page_link.rb +19 -0
  170. data/app/models/collavre/plan.rb +20 -0
  171. data/app/models/collavre/session.rb +24 -0
  172. data/app/models/collavre/system_setting.rb +144 -0
  173. data/app/models/collavre/tag.rb +10 -0
  174. data/app/models/collavre/task.rb +10 -0
  175. data/app/models/collavre/task_action.rb +10 -0
  176. data/app/models/collavre/topic.rb +12 -0
  177. data/app/models/collavre/user.rb +174 -0
  178. data/app/models/collavre/user_theme.rb +10 -0
  179. data/app/models/collavre/variation.rb +5 -0
  180. data/app/models/collavre/webauthn_credential.rb +11 -0
  181. data/app/services/collavre/ai_agent_service.rb +193 -0
  182. data/app/services/collavre/ai_client.rb +183 -0
  183. data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
  184. data/app/services/collavre/auto_theme_generator.rb +198 -0
  185. data/app/services/collavre/comment_link_formatter.rb +60 -0
  186. data/app/services/collavre/comments/action_executor.rb +262 -0
  187. data/app/services/collavre/comments/action_validator.rb +58 -0
  188. data/app/services/collavre/comments/calendar_command.rb +97 -0
  189. data/app/services/collavre/comments/command_processor.rb +37 -0
  190. data/app/services/collavre/comments/mcp_command.rb +109 -0
  191. data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
  192. data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
  193. data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
  194. data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
  195. data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
  196. data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
  197. data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
  198. data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
  199. data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
  200. data/app/services/collavre/creatives/importer.rb +47 -0
  201. data/app/services/collavre/creatives/index_query.rb +191 -0
  202. data/app/services/collavre/creatives/path_exporter.rb +131 -0
  203. data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
  204. data/app/services/collavre/creatives/permission_checker.rb +42 -0
  205. data/app/services/collavre/creatives/plan_tagger.rb +53 -0
  206. data/app/services/collavre/creatives/progress_service.rb +89 -0
  207. data/app/services/collavre/creatives/reorderer.rb +187 -0
  208. data/app/services/collavre/creatives/tree_builder.rb +231 -0
  209. data/app/services/collavre/creatives/tree_formatter.rb +36 -0
  210. data/app/services/collavre/gemini_parent_recommender.rb +77 -0
  211. data/app/services/collavre/github/client.rb +112 -0
  212. data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
  213. data/app/services/collavre/github/pull_request_processor.rb +181 -0
  214. data/app/services/collavre/github/webhook_provisioner.rb +130 -0
  215. data/app/services/collavre/google_calendar_service.rb +149 -0
  216. data/app/services/collavre/link_preview_fetcher.rb +230 -0
  217. data/app/services/collavre/markdown_importer.rb +202 -0
  218. data/app/services/collavre/mcp_service.rb +217 -0
  219. data/app/services/collavre/notion_client.rb +231 -0
  220. data/app/services/collavre/notion_creative_exporter.rb +296 -0
  221. data/app/services/collavre/notion_service.rb +249 -0
  222. data/app/services/collavre/ppt_importer.rb +76 -0
  223. data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
  224. data/app/services/collavre/system_events/context_builder.rb +41 -0
  225. data/app/services/collavre/system_events/dispatcher.rb +19 -0
  226. data/app/services/collavre/system_events/router.rb +72 -0
  227. data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
  228. data/app/views/admin/shared/_tabs.html.erb +4 -0
  229. data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
  230. data/app/views/collavre/comments/_comment.html.erb +147 -0
  231. data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
  232. data/app/views/collavre/comments/_list.html.erb +10 -0
  233. data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
  234. data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
  235. data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
  236. data/app/views/collavre/creatives/_add_button.html.erb +20 -0
  237. data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
  238. data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
  239. data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
  240. data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
  241. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
  242. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
  243. data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
  244. data/app/views/collavre/creatives/_share_button.html.erb +55 -0
  245. data/app/views/collavre/creatives/edit.html.erb +4 -0
  246. data/app/views/collavre/creatives/index.html.erb +192 -0
  247. data/app/views/collavre/creatives/new.html.erb +4 -0
  248. data/app/views/collavre/creatives/show.html.erb +29 -0
  249. data/app/views/collavre/creatives/slide_view.html.erb +20 -0
  250. data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
  251. data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
  252. data/app/views/collavre/emails/index.html.erb +19 -0
  253. data/app/views/collavre/emails/show.html.erb +5 -0
  254. data/app/views/collavre/inbox_items/_item.html.erb +18 -0
  255. data/app/views/collavre/inbox_items/_items.html.erb +3 -0
  256. data/app/views/collavre/inbox_items/_list.html.erb +7 -0
  257. data/app/views/collavre/inbox_items/index.html.erb +1 -0
  258. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
  259. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
  260. data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
  261. data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
  262. data/app/views/collavre/invites/show.html.erb +8 -0
  263. data/app/views/collavre/passwords/edit.html.erb +12 -0
  264. data/app/views/collavre/passwords/new.html.erb +17 -0
  265. data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
  266. data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
  267. data/app/views/collavre/sessions/new.html.erb +27 -0
  268. data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
  269. data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
  270. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
  271. data/app/views/collavre/shared/_navigation.html.erb +37 -0
  272. data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
  273. data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
  274. data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
  275. data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
  276. data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
  277. data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
  278. data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
  279. data/app/views/collavre/user_themes/index.html.erb +75 -0
  280. data/app/views/collavre/users/_app_header.html.erb +5 -0
  281. data/app/views/collavre/users/_contact_management.html.erb +77 -0
  282. data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
  283. data/app/views/collavre/users/edit_ai.html.erb +79 -0
  284. data/app/views/collavre/users/edit_password.html.erb +27 -0
  285. data/app/views/collavre/users/index.html.erb +80 -0
  286. data/app/views/collavre/users/new.html.erb +33 -0
  287. data/app/views/collavre/users/new_ai.html.erb +87 -0
  288. data/app/views/collavre/users/passkeys.html.erb +43 -0
  289. data/app/views/collavre/users/show.html.erb +143 -0
  290. data/app/views/inbox/badge_component/_count.html.erb +2 -0
  291. data/app/views/layouts/collavre/slide.html.erb +29 -0
  292. data/config/locales/comments.en.yml +114 -0
  293. data/config/locales/comments.ko.yml +110 -0
  294. data/config/locales/contacts.en.yml +16 -0
  295. data/config/locales/contacts.ko.yml +16 -0
  296. data/config/locales/creatives.en.yml +183 -0
  297. data/config/locales/creatives.ko.yml +164 -0
  298. data/config/locales/invites.en.yml +19 -0
  299. data/config/locales/invites.ko.yml +17 -0
  300. data/config/locales/notifications.en.yml +8 -0
  301. data/config/locales/notifications.ko.yml +8 -0
  302. data/config/locales/plans.en.yml +12 -0
  303. data/config/locales/plans.ko.yml +19 -0
  304. data/config/locales/themes.en.yml +29 -0
  305. data/config/locales/themes.ko.yml +27 -0
  306. data/config/locales/users.en.yml +151 -0
  307. data/config/locales/users.ko.yml +146 -0
  308. data/config/routes.rb +92 -0
  309. data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
  310. data/db/migrate/20250128110017_create_creatives.rb +11 -0
  311. data/db/migrate/20250128120122_create_users.rb +11 -0
  312. data/db/migrate/20250128120123_create_sessions.rb +11 -0
  313. data/db/migrate/20250128123633_create_subscribers.rb +10 -0
  314. data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
  315. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
  316. data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
  317. data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
  318. data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
  319. data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
  320. data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
  321. data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
  322. data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
  323. data/db/migrate/20250530060200_create_tags.rb +9 -0
  324. data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
  325. data/db/migrate/20250531140142_create_labels.rb +12 -0
  326. data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
  327. data/db/migrate/20250601000000_create_comments.rb +10 -0
  328. data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
  329. data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
  330. data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
  331. data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
  332. data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
  333. data/db/migrate/20250611030138_create_invitations.rb +15 -0
  334. data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
  335. data/db/migrate/20250612150000_update_permissions.rb +43 -0
  336. data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
  337. data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
  338. data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
  339. data/db/migrate/20250620004558_create_emails.rb +13 -0
  340. data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
  341. data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
  342. data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
  343. data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
  344. data/db/migrate/20250714190000_create_devices.rb +17 -0
  345. data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
  346. data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
  347. data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
  348. data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
  349. data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
  350. data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
  351. data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
  352. data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
  353. data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
  354. data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
  355. data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
  356. data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
  357. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
  358. data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
  359. data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
  360. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  361. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  362. data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
  363. data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
  364. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  365. data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
  366. data/db/migrate/20251001000001_create_contacts.rb +71 -0
  367. data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
  368. data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
  369. data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
  370. data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
  371. data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
  372. data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
  373. data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
  374. data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
  375. data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
  376. data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
  377. data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
  378. data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
  379. data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
  380. data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
  381. data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
  382. data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
  383. data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
  384. data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
  385. data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
  386. data/db/migrate/20251223072625_create_user_themes.rb +11 -0
  387. data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
  388. data/db/migrate/20251230113607_refactor_labels.rb +38 -0
  389. data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
  390. data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
  391. data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
  392. data/db/migrate/20260106160643_create_system_settings.rb +11 -0
  393. data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
  394. data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
  395. data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
  396. data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
  397. data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
  398. data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
  399. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
  400. data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
  401. data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
  402. data/lib/collavre/configuration.rb +14 -0
  403. data/lib/collavre/engine.rb +77 -0
  404. data/lib/collavre/user_extensions.rb +29 -0
  405. data/lib/collavre/version.rb +3 -0
  406. data/lib/collavre.rb +26 -0
  407. data/lib/generators/collavre/install/install_generator.rb +105 -0
  408. data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
  409. data/lib/tasks/collavre_assets.rake +15 -0
  410. metadata +591 -0
@@ -0,0 +1,296 @@
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
@@ -0,0 +1,249 @@
1
+ module Collavre
2
+ require "digest"
3
+
4
+ class NotionService
5
+ def initialize(user:)
6
+ @user = user
7
+ @account = user.notion_account
8
+ raise NotionAuthError, "No Notion account found" unless @account
9
+ end
10
+
11
+ def client
12
+ @client ||= NotionClient.new(@account)
13
+ end
14
+
15
+ def search_pages(query: nil, start_cursor: nil, page_size: 10)
16
+ with_token_refresh { client.search_pages(query: query, start_cursor: start_cursor, page_size: page_size) }
17
+ end
18
+
19
+ def get_page(page_id)
20
+ with_token_refresh { client.get_page(page_id) }
21
+ end
22
+
23
+ def create_page(parent_id:, title:, blocks: [])
24
+ with_token_refresh { client.create_page(parent_id: parent_id, title: title, blocks: blocks) }
25
+ end
26
+
27
+ def update_page(page_id, properties: {}, blocks: nil)
28
+ with_token_refresh { client.update_page(page_id, properties: properties, blocks: blocks) }
29
+ end
30
+
31
+ def get_page_blocks(page_id, start_cursor: nil, page_size: 100)
32
+ with_token_refresh { client.get_page_blocks(page_id, start_cursor: start_cursor, page_size: page_size) }
33
+ end
34
+
35
+ def replace_page_blocks(page_id, blocks)
36
+ with_token_refresh { client.replace_page_blocks(page_id, blocks) }
37
+ end
38
+
39
+ def append_blocks(parent_id, blocks)
40
+ with_token_refresh { client.append_blocks(parent_id, blocks) }
41
+ end
42
+
43
+ def delete_block(block_id)
44
+ with_token_refresh { client.delete_block(block_id) }
45
+ end
46
+
47
+ def get_workspace
48
+ with_token_refresh { client.get_workspace }
49
+ end
50
+
51
+ # Create or update a page for a creative
52
+ def sync_creative(creative, parent_page_id: nil)
53
+ notion_link = find_or_create_page_link(creative, parent_page_id)
54
+
55
+ if notion_link.page_id.present?
56
+ # Update existing page
57
+ update_creative_page(creative, notion_link)
58
+ else
59
+ # Create new page
60
+ create_creative_page(creative, notion_link, parent_page_id)
61
+ end
62
+
63
+ notion_link.mark_synced!
64
+ notion_link
65
+ end
66
+
67
+ private
68
+
69
+ def with_token_refresh(&block)
70
+ yield
71
+ rescue NotionAuthError => e
72
+ if refresh_token!
73
+ @client = nil # Reset client with new token
74
+ yield
75
+ else
76
+ raise e
77
+ end
78
+ end
79
+
80
+ def refresh_token!
81
+ # Notion uses OAuth 2.0 but doesn't issue refresh tokens in the same way as Google
82
+ # For now, we'll just log the error and return false
83
+ # In a production app, you'd implement proper token refresh logic here
84
+ Rails.logger.error("Notion token refresh needed but not implemented")
85
+ false
86
+ end
87
+
88
+ def find_or_create_page_link(creative, parent_page_id)
89
+ @account.notion_page_links.find_or_initialize_by(creative: creative) do |link|
90
+ link.parent_page_id = parent_page_id
91
+ end
92
+ end
93
+
94
+ def create_creative_page(creative, notion_link, parent_page_id)
95
+ title = ActionController::Base.helpers.strip_tags(creative.description).strip.presence || "Untitled Creative"
96
+
97
+ # Export only the children - the page title serves as the root creative
98
+ children = creative.children.to_a
99
+ Rails.logger.info("NotionService: Exporting creative #{creative.id} as page title with #{children.count} children as blocks")
100
+
101
+ exporter = NotionCreativeExporter.new(creative)
102
+ blocks = []
103
+
104
+ # If no parent specified, search for a suitable workspace page
105
+ parent_page_id ||= find_default_parent_page
106
+
107
+ response = create_page(
108
+ parent_id: parent_page_id,
109
+ title: title,
110
+ blocks: blocks
111
+ )
112
+
113
+ notion_link.update!(
114
+ page_id: response["id"],
115
+ page_title: title,
116
+ page_url: response["url"],
117
+ parent_page_id: parent_page_id
118
+ )
119
+
120
+ sync_child_blocks(notion_link, creative, children, exporter)
121
+
122
+ response
123
+ end
124
+
125
+ def update_creative_page(creative, notion_link)
126
+ title = ActionController::Base.helpers.strip_tags(creative.description).strip.presence || "Untitled Creative"
127
+
128
+ # Update with only the children - page title serves as the root creative
129
+ children = creative.children.to_a
130
+ Rails.logger.info("NotionService: Updating creative #{creative.id} as page title with #{children.count} children as blocks")
131
+
132
+ exporter = NotionCreativeExporter.new(creative)
133
+
134
+ properties = {
135
+ title: {
136
+ title: [ { text: { content: title } } ]
137
+ }
138
+ }
139
+
140
+ update_page(notion_link.page_id, properties: properties)
141
+ notion_link.update!(page_title: title)
142
+
143
+ sync_child_blocks(notion_link, creative, children, exporter)
144
+ end
145
+
146
+ def find_default_parent_page
147
+ # Search for pages in the workspace to find a suitable parent
148
+ pages = search_pages(page_size: 1)
149
+ pages.dig("results")&.first&.dig("id") || raise(NotionError, "No accessible pages found in workspace")
150
+ end
151
+
152
+ def sync_child_blocks(notion_link, creative, children, exporter)
153
+ child_ids = children.map(&:id)
154
+ existing_links = notion_link.notion_block_links.includes(:creative).order(:created_at).to_a
155
+ existing_links_by_creative = existing_links.group_by(&:creative_id)
156
+
157
+ page_blocks = existing_links.any? ? fetch_all_page_blocks(notion_link.page_id) : []
158
+ page_block_ids = page_blocks.map { |block| block["id"] }
159
+
160
+ block_to_creative = {}
161
+ existing_links.each do |link|
162
+ block_to_creative[link.block_id] = link.creative_id if page_block_ids.include?(link.block_id)
163
+ end
164
+
165
+ existing_order = page_block_ids.map { |block_id| block_to_creative[block_id] }.compact.uniq
166
+ expected_order = child_ids.select { |id| existing_links_by_creative.key?(id) }
167
+ reorder_detected = existing_order != expected_order
168
+
169
+ removed_ids = existing_links_by_creative.keys - child_ids
170
+ changes_detected = removed_ids.any?
171
+
172
+ child_exports = children.map do |child|
173
+ exported_blocks = exporter.export_tree_blocks([ child ], 1, 0)
174
+ content_hash = Digest::SHA256.hexdigest(exported_blocks.to_json)
175
+ links = existing_links_by_creative[child.id] || []
176
+ missing_blocks = links.any? { |link| !page_block_ids.include?(link.block_id) }
177
+
178
+ if exported_blocks.empty?
179
+ changes_detected ||= links.present?
180
+ else
181
+ changes_detected ||= links.blank?
182
+ changes_detected ||= links.size != exported_blocks.size
183
+ changes_detected ||= links.first.content_hash != content_hash
184
+ changes_detected ||= missing_blocks
185
+ end
186
+
187
+ {
188
+ child: child,
189
+ exported_blocks: exported_blocks,
190
+ content_hash: content_hash
191
+ }
192
+ end
193
+
194
+ unless changes_detected || reorder_detected
195
+ return
196
+ end
197
+
198
+ blocks_to_clear = page_blocks.presence || fetch_all_page_blocks(notion_link.page_id)
199
+ blocks_to_clear.each do |block|
200
+ begin
201
+ delete_block(block["id"])
202
+ rescue NotionError => e
203
+ Rails.logger.warn("NotionService: Failed to delete Notion block #{block['id']} during resync: #{e.message}")
204
+ end
205
+ end
206
+
207
+ NotionBlockLink.transaction do
208
+ notion_link.notion_block_links.delete_all
209
+
210
+ child_exports.each do |data|
211
+ exported_blocks = data[:exported_blocks]
212
+ next if exported_blocks.empty?
213
+
214
+ response = append_blocks(notion_link.page_id, exported_blocks)
215
+ response_results = response.is_a?(Hash) ? response.fetch("results", []) : []
216
+ new_block_ids = response_results.filter_map { |result| result["id"] }
217
+
218
+ if new_block_ids.empty?
219
+ Rails.logger.warn("NotionService: Unable to determine new block ids for creative #{data[:child].id}")
220
+ next
221
+ end
222
+
223
+ new_block_ids.each do |block_id|
224
+ notion_link.notion_block_links.create!(
225
+ creative: data[:child],
226
+ block_id: block_id,
227
+ content_hash: data[:content_hash]
228
+ )
229
+ end
230
+ end
231
+ end
232
+ end
233
+
234
+ def fetch_all_page_blocks(page_id)
235
+ blocks = []
236
+ cursor = nil
237
+
238
+ loop do
239
+ response = get_page_blocks(page_id, start_cursor: cursor)
240
+ blocks.concat(response.fetch("results", []))
241
+ break unless response["has_more"]
242
+
243
+ cursor = response["next_cursor"]
244
+ end
245
+
246
+ blocks
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,76 @@
1
+ module Collavre
2
+ class PptImporter
3
+ # Import each slide from a PowerPoint file as HTML and create Creatives.
4
+ def self.import(file, parent:, user:, create_root: false, filename: nil)
5
+ require "zip"
6
+ require "nokogiri"
7
+ require "base64"
8
+ require "pathname"
9
+ require "erb"
10
+
11
+ created = []
12
+ root = parent
13
+ if create_root
14
+ title = filename ? File.basename(filename, File.extname(filename)) : "Presentation"
15
+ root = Creative.create(user: user, parent: parent, description: ERB::Util.html_escape(title))
16
+ created << root
17
+ end
18
+
19
+ Zip::File.open(file) do |zip|
20
+ slide_entries = zip.glob("ppt/slides/slide*.xml").sort_by do |entry|
21
+ entry.name[/slide(\d+)/, 1].to_i
22
+ end
23
+
24
+ slide_entries.each do |entry|
25
+ slide_number = entry.name[/slide(\d+)/, 1]
26
+ xml = Nokogiri::XML(entry.get_input_stream.read)
27
+ ns = xml.collect_namespaces
28
+ html_fragments = []
29
+
30
+ # Extract paragraphs as HTML
31
+ xml.xpath("//p:sp//a:p", ns).each do |p_node|
32
+ text = p_node.xpath(".//a:t", ns).map(&:text).join
33
+ next if text.strip.empty?
34
+ html_fragments << "<p>#{ERB::Util.html_escape(text)}</p>"
35
+ end
36
+
37
+ # Map relationship IDs to targets for image lookup
38
+ rels_name = "ppt/slides/_rels/slide#{slide_number}.xml.rels"
39
+ relationships = {}
40
+ if (rels_entry = zip.find_entry(rels_name))
41
+ rel_xml = Nokogiri::XML(rels_entry.get_input_stream.read)
42
+ rel_xml.xpath("//xmlns:Relationship").each do |rel|
43
+ relationships[rel["Id"]] = rel["Target"]
44
+ end
45
+ end
46
+
47
+ # Extract images and embed as base64 data URLs
48
+ xml.xpath("//p:pic", ns).each do |pic|
49
+ blip = pic.at_xpath(".//a:blip", ns)
50
+ next unless blip
51
+ embed_id = blip["r:embed"]
52
+ target = relationships[embed_id]
53
+ next unless target
54
+ img_path = Pathname.new("ppt/slides").join(target).cleanpath.to_s
55
+ img_entry = zip.find_entry(img_path)
56
+ next unless img_entry
57
+ data = img_entry.get_input_stream.read
58
+ mime = case File.extname(img_path).downcase
59
+ when ".png" then "image/png"
60
+ when ".jpg", ".jpeg" then "image/jpeg"
61
+ when ".gif" then "image/gif"
62
+ else "application/octet-stream"
63
+ end
64
+ base64 = Base64.strict_encode64(data)
65
+ html_fragments << "<img src=\"data:#{mime};base64,#{base64}\" />"
66
+ end
67
+
68
+ c = Creative.create(user: user, parent: root, description: html_fragments.join)
69
+ created << c
70
+ end
71
+ end
72
+
73
+ created
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,34 @@
1
+ module Collavre
2
+ # frozen_string_literal: true
3
+
4
+ class RubyLlmInteractionLogger
5
+ class << self
6
+ def log(vendor:, model:, messages:, tools: [], response_content: nil, error_message: nil, activity: "llm_query", creative: nil, user: nil, comment: nil, input_tokens: nil, output_tokens: nil)
7
+ ActivityLog.create!(
8
+ activity: activity,
9
+ creative: creative,
10
+ user: user,
11
+ comment: comment,
12
+ log: {
13
+ vendor: vendor.presence || "unknown",
14
+ model: model.to_s,
15
+ messages: safe_json(messages || []),
16
+ tools: safe_json(tools || []),
17
+ response_content: response_content,
18
+ error_message: error_message,
19
+ input_tokens: input_tokens,
20
+ output_tokens: output_tokens
21
+ }
22
+ )
23
+ rescue StandardError => e
24
+ Rails.logger.error("Failed to persist activity log: #{e.class} #{e.message}")
25
+ end
26
+
27
+ private
28
+
29
+ def safe_json(value)
30
+ JSON.parse(value.to_json)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ module Collavre
2
+ module SystemEvents
3
+ class ContextBuilder
4
+ def initialize(context)
5
+ @context = context
6
+ end
7
+
8
+ def build
9
+ # Ensure context is a hash with string keys for Liquid
10
+ ctx = @context.deep_stringify_keys
11
+
12
+ # Add helper objects/functions
13
+ if ctx["chat"]
14
+ ctx["chat"]["mentioned_user"] ||= mentioned_user(ctx["chat"])
15
+ end
16
+
17
+ ctx
18
+ end
19
+
20
+ private
21
+
22
+ def mentioned_user(chat_context)
23
+ # This mimics the chat.mentioned_user function requested
24
+ # It assumes chat_context has 'content' or similar, or we might need to look up the comment
25
+ # For now, let's assume the context passed in already has the necessary info or we extract it.
26
+ # If the event is comment_created, the payload usually has the comment content.
27
+
28
+ content = chat_context["content"]
29
+ return nil unless content
30
+
31
+ # Simple regex to find the first mention
32
+ match = content.match(/\A@([^:]+?):\s*/) || content.match(/\A@(\S+)\s+/)
33
+ return nil unless match
34
+
35
+ name = match[1].strip
36
+ user = User.where("LOWER(name) = ?", name.downcase).first
37
+ user&.as_json(only: [ :id, :name, :email ])
38
+ end
39
+ end
40
+ end
41
+ end