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.
- checksums.yaml +7 -0
- data/README.md +221 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/collavre/actiontext.css +577 -0
- data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
- data/app/assets/stylesheets/collavre/creatives.css +559 -0
- data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
- data/app/assets/stylesheets/collavre/popup.css +160 -0
- data/app/assets/stylesheets/collavre/print.css +37 -0
- data/app/assets/stylesheets/collavre/slide_view.css +79 -0
- data/app/assets/stylesheets/collavre/user_menu.css +34 -0
- data/app/channels/collavre/comments_presence_channel.rb +54 -0
- data/app/channels/collavre/slide_view_channel.rb +11 -0
- data/app/channels/collavre/topics_channel.rb +12 -0
- data/app/components/collavre/avatar_component.html.erb +15 -0
- data/app/components/collavre/avatar_component.rb +59 -0
- data/app/components/collavre/inbox/badge_component.html.erb +6 -0
- data/app/components/collavre/inbox/badge_component.rb +18 -0
- data/app/components/collavre/plans_timeline_component.html.erb +14 -0
- data/app/components/collavre/plans_timeline_component.rb +56 -0
- data/app/components/collavre/popup_menu_component.html.erb +6 -0
- data/app/components/collavre/popup_menu_component.rb +30 -0
- data/app/components/collavre/progress_filter_component.html.erb +5 -0
- data/app/components/collavre/progress_filter_component.rb +10 -0
- data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
- data/app/components/collavre/user_mention_menu_component.rb +8 -0
- data/app/controllers/collavre/application_controller.rb +15 -0
- data/app/controllers/collavre/attachments_controller.rb +44 -0
- data/app/controllers/collavre/calendar_events_controller.rb +15 -0
- data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
- data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +464 -0
- data/app/controllers/collavre/contacts_controller.rb +10 -0
- data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
- data/app/controllers/collavre/creative_imports_controller.rb +24 -0
- data/app/controllers/collavre/creative_plans_controller.rb +69 -0
- data/app/controllers/collavre/creative_shares_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +535 -0
- data/app/controllers/collavre/devices_controller.rb +19 -0
- data/app/controllers/collavre/email_verifications_controller.rb +16 -0
- data/app/controllers/collavre/emails_controller.rb +11 -0
- data/app/controllers/collavre/github_auth_controller.rb +25 -0
- data/app/controllers/collavre/google_auth_controller.rb +43 -0
- data/app/controllers/collavre/inbox_items_controller.rb +64 -0
- data/app/controllers/collavre/invites_controller.rb +27 -0
- data/app/controllers/collavre/notion_auth_controller.rb +25 -0
- data/app/controllers/collavre/passwords_controller.rb +37 -0
- data/app/controllers/collavre/plans_controller.rb +110 -0
- data/app/controllers/collavre/sessions_controller.rb +57 -0
- data/app/controllers/collavre/topics_controller.rb +58 -0
- data/app/controllers/collavre/user_themes_controller.rb +58 -0
- data/app/controllers/collavre/users_controller.rb +390 -0
- data/app/helpers/collavre/application_helper.rb +4 -0
- data/app/helpers/collavre/comments_helper.rb +9 -0
- data/app/helpers/collavre/creatives_helper.rb +343 -0
- data/app/helpers/collavre/navigation_helper.rb +163 -0
- data/app/helpers/collavre/user_themes_helper.rb +4 -0
- data/app/javascript/collavre.js +26 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
- data/app/javascript/components/LinkPopup.jsx +112 -0
- data/app/javascript/components/creative_tree_row.js +503 -0
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
- data/app/javascript/controllers/click_target_controller.js +13 -0
- data/app/javascript/controllers/comment_controller.js +162 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
- data/app/javascript/controllers/comments/form_controller.js +530 -0
- data/app/javascript/controllers/comments/list_controller.js +715 -0
- data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
- data/app/javascript/controllers/comments/popup_controller.js +385 -0
- data/app/javascript/controllers/comments/presence_controller.js +311 -0
- data/app/javascript/controllers/comments/topics_controller.js +338 -0
- data/app/javascript/controllers/common_popup_controller.js +55 -0
- data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
- data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
- data/app/javascript/controllers/creatives/import_controller.js +116 -0
- data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
- data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
- data/app/javascript/controllers/creatives/tree_controller.js +218 -0
- data/app/javascript/controllers/index.js +79 -0
- data/app/javascript/controllers/link_creative_controller.js +91 -0
- data/app/javascript/controllers/popup_menu_controller.js +82 -0
- data/app/javascript/controllers/progress_filter_controller.js +35 -0
- data/app/javascript/controllers/reaction_picker_controller.js +107 -0
- data/app/javascript/controllers/share_invite_controller.js +15 -0
- data/app/javascript/controllers/share_user_search_controller.js +121 -0
- data/app/javascript/controllers/tabs_controller.js +43 -0
- data/app/javascript/creatives/drag_drop/dom.js +170 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
- data/app/javascript/creatives/drag_drop/indicator.js +35 -0
- data/app/javascript/creatives/drag_drop/operations.js +116 -0
- data/app/javascript/creatives/drag_drop/state.js +31 -0
- data/app/javascript/creatives/tree_renderer.js +248 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
- data/app/javascript/lib/api/creatives.js +79 -0
- data/app/javascript/lib/api/csrf_fetch.js +22 -0
- data/app/javascript/lib/api/drag_drop.js +31 -0
- data/app/javascript/lib/api/queue_manager.js +423 -0
- data/app/javascript/lib/apply_lexical_styles.js +15 -0
- data/app/javascript/lib/common_popup.js +195 -0
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
- data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
- data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
- data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
- data/app/javascript/lib/lexical/attachment_payload.js +293 -0
- data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
- data/app/javascript/lib/lexical/image_node.jsx +159 -0
- data/app/javascript/lib/lexical/style_attributes.js +40 -0
- data/app/javascript/lib/responsive_images.js +54 -0
- data/app/javascript/lib/turbo_stream_actions.js +33 -0
- data/app/javascript/lib/utils/markdown.js +23 -0
- data/app/javascript/modules/creative_guide.js +53 -0
- data/app/javascript/modules/creative_row_editor.js +1841 -0
- data/app/javascript/modules/creative_row_swipe.js +43 -0
- data/app/javascript/modules/creatives.js +15 -0
- data/app/javascript/modules/export_to_markdown.js +34 -0
- data/app/javascript/modules/inbox_panel.js +226 -0
- data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
- data/app/javascript/modules/mention_menu.js +77 -0
- data/app/javascript/modules/plans_menu.js +39 -0
- data/app/javascript/modules/plans_timeline.js +397 -0
- data/app/javascript/modules/share_modal.js +73 -0
- data/app/javascript/modules/share_user_popup.js +77 -0
- data/app/javascript/modules/slide_view.js +163 -0
- data/app/javascript/services/cable.js +32 -0
- data/app/javascript/slide_view.js +2 -0
- data/app/javascript/utils/caret_position.js +42 -0
- data/app/javascript/utils/clipboard.js +40 -0
- data/app/jobs/collavre/ai_agent_job.rb +27 -0
- data/app/jobs/collavre/inbox_summary_job.rb +24 -0
- data/app/jobs/collavre/notion_export_job.rb +30 -0
- data/app/jobs/collavre/notion_sync_job.rb +48 -0
- data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
- data/app/jobs/collavre/permission_cache_job.rb +71 -0
- data/app/jobs/collavre/push_notification_job.rb +86 -0
- data/app/mailers/collavre/application_mailer.rb +17 -0
- data/app/mailers/collavre/creative_mailer.rb +9 -0
- data/app/mailers/collavre/email_verification_mailer.rb +20 -0
- data/app/mailers/collavre/inbox_mailer.rb +19 -0
- data/app/mailers/collavre/invitation_mailer.rb +16 -0
- data/app/mailers/collavre/passwords_mailer.rb +10 -0
- data/app/models/collavre/activity_log.rb +13 -0
- data/app/models/collavre/application_record.rb +5 -0
- data/app/models/collavre/calendar_event.rb +20 -0
- data/app/models/collavre/comment.rb +307 -0
- data/app/models/collavre/comment_presence_store.rb +30 -0
- data/app/models/collavre/comment_reaction.rb +11 -0
- data/app/models/collavre/comment_read_pointer.rb +26 -0
- data/app/models/collavre/contact.rb +23 -0
- data/app/models/collavre/creative.rb +413 -0
- data/app/models/collavre/creative_expanded_state.rb +11 -0
- data/app/models/collavre/creative_share.rb +122 -0
- data/app/models/collavre/creative_shares_cache.rb +18 -0
- data/app/models/collavre/current.rb +14 -0
- data/app/models/collavre/device.rb +13 -0
- data/app/models/collavre/email.rb +14 -0
- data/app/models/collavre/github_account.rb +10 -0
- data/app/models/collavre/github_repository_link.rb +19 -0
- data/app/models/collavre/inbox_item.rb +95 -0
- data/app/models/collavre/invitation.rb +22 -0
- data/app/models/collavre/label.rb +47 -0
- data/app/models/collavre/mcp_tool.rb +30 -0
- data/app/models/collavre/notion_account.rb +17 -0
- data/app/models/collavre/notion_block_link.rb +10 -0
- data/app/models/collavre/notion_page_link.rb +19 -0
- data/app/models/collavre/plan.rb +20 -0
- data/app/models/collavre/session.rb +24 -0
- data/app/models/collavre/system_setting.rb +144 -0
- data/app/models/collavre/tag.rb +10 -0
- data/app/models/collavre/task.rb +10 -0
- data/app/models/collavre/task_action.rb +10 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +174 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/models/collavre/variation.rb +5 -0
- data/app/models/collavre/webauthn_credential.rb +11 -0
- data/app/services/collavre/ai_agent_service.rb +193 -0
- data/app/services/collavre/ai_client.rb +183 -0
- data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
- data/app/services/collavre/auto_theme_generator.rb +198 -0
- data/app/services/collavre/comment_link_formatter.rb +60 -0
- data/app/services/collavre/comments/action_executor.rb +262 -0
- data/app/services/collavre/comments/action_validator.rb +58 -0
- data/app/services/collavre/comments/calendar_command.rb +97 -0
- data/app/services/collavre/comments/command_processor.rb +37 -0
- data/app/services/collavre/comments/mcp_command.rb +109 -0
- data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
- data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
- data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
- data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
- data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
- data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
- data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
- data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
- data/app/services/collavre/creatives/importer.rb +47 -0
- data/app/services/collavre/creatives/index_query.rb +191 -0
- data/app/services/collavre/creatives/path_exporter.rb +131 -0
- data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
- data/app/services/collavre/creatives/permission_checker.rb +42 -0
- data/app/services/collavre/creatives/plan_tagger.rb +53 -0
- data/app/services/collavre/creatives/progress_service.rb +89 -0
- data/app/services/collavre/creatives/reorderer.rb +187 -0
- data/app/services/collavre/creatives/tree_builder.rb +231 -0
- data/app/services/collavre/creatives/tree_formatter.rb +36 -0
- data/app/services/collavre/gemini_parent_recommender.rb +77 -0
- data/app/services/collavre/github/client.rb +112 -0
- data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
- data/app/services/collavre/github/pull_request_processor.rb +181 -0
- data/app/services/collavre/github/webhook_provisioner.rb +130 -0
- data/app/services/collavre/google_calendar_service.rb +149 -0
- data/app/services/collavre/link_preview_fetcher.rb +230 -0
- data/app/services/collavre/markdown_importer.rb +202 -0
- data/app/services/collavre/mcp_service.rb +217 -0
- data/app/services/collavre/notion_client.rb +231 -0
- data/app/services/collavre/notion_creative_exporter.rb +296 -0
- data/app/services/collavre/notion_service.rb +249 -0
- data/app/services/collavre/ppt_importer.rb +76 -0
- data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
- data/app/services/collavre/system_events/context_builder.rb +41 -0
- data/app/services/collavre/system_events/dispatcher.rb +19 -0
- data/app/services/collavre/system_events/router.rb +72 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
- data/app/views/admin/shared/_tabs.html.erb +4 -0
- data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
- data/app/views/collavre/comments/_comment.html.erb +147 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
- data/app/views/collavre/comments/_list.html.erb +10 -0
- data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
- data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
- data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
- data/app/views/collavre/creatives/_add_button.html.erb +20 -0
- data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
- data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
- data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
- data/app/views/collavre/creatives/_share_button.html.erb +55 -0
- data/app/views/collavre/creatives/edit.html.erb +4 -0
- data/app/views/collavre/creatives/index.html.erb +192 -0
- data/app/views/collavre/creatives/new.html.erb +4 -0
- data/app/views/collavre/creatives/show.html.erb +29 -0
- data/app/views/collavre/creatives/slide_view.html.erb +20 -0
- data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
- data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
- data/app/views/collavre/emails/index.html.erb +19 -0
- data/app/views/collavre/emails/show.html.erb +5 -0
- data/app/views/collavre/inbox_items/_item.html.erb +18 -0
- data/app/views/collavre/inbox_items/_items.html.erb +3 -0
- data/app/views/collavre/inbox_items/_list.html.erb +7 -0
- data/app/views/collavre/inbox_items/index.html.erb +1 -0
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
- data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
- data/app/views/collavre/invites/show.html.erb +8 -0
- data/app/views/collavre/passwords/edit.html.erb +12 -0
- data/app/views/collavre/passwords/new.html.erb +17 -0
- data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
- data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
- data/app/views/collavre/sessions/new.html.erb +27 -0
- data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
- data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
- data/app/views/collavre/shared/_navigation.html.erb +37 -0
- data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
- data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
- data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
- data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
- data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
- data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
- data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
- data/app/views/collavre/user_themes/index.html.erb +75 -0
- data/app/views/collavre/users/_app_header.html.erb +5 -0
- data/app/views/collavre/users/_contact_management.html.erb +77 -0
- data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
- data/app/views/collavre/users/edit_ai.html.erb +79 -0
- data/app/views/collavre/users/edit_password.html.erb +27 -0
- data/app/views/collavre/users/index.html.erb +80 -0
- data/app/views/collavre/users/new.html.erb +33 -0
- data/app/views/collavre/users/new_ai.html.erb +87 -0
- data/app/views/collavre/users/passkeys.html.erb +43 -0
- data/app/views/collavre/users/show.html.erb +143 -0
- data/app/views/inbox/badge_component/_count.html.erb +2 -0
- data/app/views/layouts/collavre/slide.html.erb +29 -0
- data/config/locales/comments.en.yml +114 -0
- data/config/locales/comments.ko.yml +110 -0
- data/config/locales/contacts.en.yml +16 -0
- data/config/locales/contacts.ko.yml +16 -0
- data/config/locales/creatives.en.yml +183 -0
- data/config/locales/creatives.ko.yml +164 -0
- data/config/locales/invites.en.yml +19 -0
- data/config/locales/invites.ko.yml +17 -0
- data/config/locales/notifications.en.yml +8 -0
- data/config/locales/notifications.ko.yml +8 -0
- data/config/locales/plans.en.yml +12 -0
- data/config/locales/plans.ko.yml +19 -0
- data/config/locales/themes.en.yml +29 -0
- data/config/locales/themes.ko.yml +27 -0
- data/config/locales/users.en.yml +151 -0
- data/config/locales/users.ko.yml +146 -0
- data/config/routes.rb +92 -0
- data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
- data/db/migrate/20250128110017_create_creatives.rb +11 -0
- data/db/migrate/20250128120122_create_users.rb +11 -0
- data/db/migrate/20250128120123_create_sessions.rb +11 -0
- data/db/migrate/20250128123633_create_subscribers.rb +10 -0
- data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
- data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
- data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
- data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
- data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
- data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
- data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
- data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
- data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
- data/db/migrate/20250530060200_create_tags.rb +9 -0
- data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
- data/db/migrate/20250531140142_create_labels.rb +12 -0
- data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
- data/db/migrate/20250601000000_create_comments.rb +10 -0
- data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
- data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
- data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
- data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
- data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
- data/db/migrate/20250611030138_create_invitations.rb +15 -0
- data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
- data/db/migrate/20250612150000_update_permissions.rb +43 -0
- data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
- data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
- data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
- data/db/migrate/20250620004558_create_emails.rb +13 -0
- data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
- data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
- data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
- data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
- data/db/migrate/20250714190000_create_devices.rb +17 -0
- data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
- data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
- data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
- data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
- data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
- data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
- data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
- data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
- data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
- data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
- data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
- data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
- data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
- data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
- data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
- data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
- data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
- data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
- data/db/migrate/20251001000001_create_contacts.rb +71 -0
- data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
- data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
- data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
- data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
- data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
- data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
- data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
- data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
- data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
- data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
- data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
- data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
- data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
- data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
- data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
- data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
- data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
- data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
- data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
- data/db/migrate/20251223072625_create_user_themes.rb +11 -0
- data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
- data/db/migrate/20251230113607_refactor_labels.rb +38 -0
- data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
- data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
- data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
- data/db/migrate/20260106160643_create_system_settings.rb +11 -0
- data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
- data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
- data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
- data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
- data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
- data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
- data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
- data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
- data/lib/collavre/configuration.rb +14 -0
- data/lib/collavre/engine.rb +77 -0
- data/lib/collavre/user_extensions.rb +29 -0
- data/lib/collavre/version.rb +3 -0
- data/lib/collavre.rb +26 -0
- data/lib/generators/collavre/install/install_generator.rb +105 -0
- data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
- data/lib/tasks/collavre_assets.rake +15 -0
- 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
|