diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4711ba5f..6db8e5f0 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -17,10 +17,37 @@ jobs: # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + mysql: + image: mysql:8.0 + env: + MYSQL_DATABASE: "maglev_engine_test" + MYSQL_ROOT_PASSWORD: "password" + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mariadb: + image: mariadb:11.8 + env: + MARIADB_DATABASE: "maglev_engine_test" + MARIADB_ROOT_PASSWORD: "password" + ports: + - 3307:3306 + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 + strategy: matrix: - node: [20, 23] - gemfile: ["Gemfile.rails_7_0", "Gemfile.rails_7_2", "Gemfile"] + gemfile: ["Gemfile", "Gemfile.rails_7_0", "Gemfile.rails_7_2"] + database: [postgres, sqlite, mysql, mariadb] + exclude: + - gemfile: Gemfile.rails_7_0 + database: mysql + - gemfile: Gemfile.rails_7_0 + database: mariadb steps: - name: Checkout code @@ -41,17 +68,22 @@ jobs: run: | corepack enable - - name: Use Node.js ${{ matrix.node }} + - name: Use Node.js v23 uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node }} + node-version: 23 cache: yarn - name: Install packages run: | yarn install - - name: Setup test database + - name: Run Javascript tests + run: yarn test + + # === Postgresql 🐘 === + - name: Setup test database (Postgresql) 🐘 + if: ${{ matrix.database == 'postgres' }} env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} RAILS_ENV: test @@ -60,14 +92,17 @@ jobs: run: | bin/rails db:setup - - name: Run Rails tests + - name: Run Rails tests (Postgresql) 🐘 + if: ${{ matrix.database == 'postgres' }} env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} MAGLEV_APP_DATABASE_USERNAME: "maglev" MAGLEV_APP_DATABASE_PASSWORD: "password" - run: bundle exec rspec + run: bundle exec rspec - - name: Setup test database (SQLite) + # === SQLite πŸͺ½ === + - name: Setup test database (SQLite) πŸͺ½ + if: ${{ matrix.database == 'sqlite' }} env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} RAILS_ENV: test @@ -79,21 +114,64 @@ jobs: cp spec/legacy_dummy/db/schema.sqlite.rb spec/legacy_dummy/db/schema.rb bin/rails db:setup - - name: Run Rails tests (SQLite) + - name: Run Rails tests (SQLite) πŸͺ½ + if: ${{ matrix.database == 'sqlite' }} env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} - USE_SQLITE: true + USE_SQLITE: 1 run: bundle exec rspec - - name: Cleanup DB schema files + # === MYSQL 🐬 === + - name: Setup test database (MySQL) 🐬 + if: ${{ matrix.database == 'mysql' }} + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + RAILS_ENV: test + USE_MYSQL: 1 + MAGLEV_APP_DATABASE_HOST: "127.0.0.1" + MAGLEV_APP_DATABASE_USERNAME: "root" + MAGLEV_APP_DATABASE_PASSWORD: "password" run: | - cp spec/dummy/db/schema.pg.rb spec/dummy/db/schema.rb - cp spec/legacy_dummy/db/schema.pg.rb spec/legacy_dummy/db/schema.rb - rm -f spec/dummy/db/maglev_engine_test.sqlite3 - rm -f spec/legacy_dummy/db/maglev_engine_test.sqlite3 + cp spec/dummy/db/schema.mysql.rb spec/dummy/db/schema.rb + cp spec/legacy_dummy/db/schema.mysql.rb spec/legacy_dummy/db/schema.rb + bin/rails db:setup - - name: Run Javascript tests - run: yarn test + - name: Run Rails tests (MySQL) 🐬 + if: ${{ matrix.database == 'mysql' }} + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + USE_MYSQL: 1 + MAGLEV_APP_DATABASE_HOST: "127.0.0.1" + MAGLEV_APP_DATABASE_USERNAME: "root" + MAGLEV_APP_DATABASE_PASSWORD: "password" + run: bundle exec rspec + + # === MariaDB 🦭=== + - name: Setup test database (MariaDB) 🦭 + if: ${{ matrix.database == 'mariadb' }} + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + RAILS_ENV: test + USE_MYSQL: 1 + MAGLEV_APP_DATABASE_HOST: "127.0.0.1" + MAGLEV_APP_DATABASE_PORT: 3307 + MAGLEV_APP_DATABASE_USERNAME: "root" + MAGLEV_APP_DATABASE_PASSWORD: "password" + run: | + cp spec/dummy/db/schema.mariadb.rb spec/dummy/db/schema.rb + cp spec/legacy_dummy/db/schema.mariadb.rb spec/legacy_dummy/db/schema.rb + bin/rails db:setup + + - name: Run Rails tests (MariaDB) 🦭 + if: ${{ matrix.database == 'mariadb' }} + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + USE_MYSQL: 1 + MAGLEV_APP_DATABASE_HOST: "127.0.0.1" + MAGLEV_APP_DATABASE_PORT: 3307 + MAGLEV_APP_DATABASE_USERNAME: "root" + MAGLEV_APP_DATABASE_PASSWORD: "password" + run: bundle exec rspec # NOTE: disabled because an error of eslint in the GH env # - name: Run Javascript linter diff --git a/.gitignore b/.gitignore index 11e7ed5e..f2ed2328 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,8 @@ bin/test spec/dummy/db/*.sqlite3 spec/legacy_dummy/db/*.sqlite3 +docker-compose.yml +db/mysql/init/01-init-databases.sql + docs/ TODO.md \ No newline at end of file diff --git a/Gemfile b/Gemfile index 69b5a9e5..1493e788 100644 --- a/Gemfile +++ b/Gemfile @@ -30,7 +30,8 @@ gem 'puma' # To use a debugger # gem 'byebug', group: [:development, :test] -# Use SQLite/PostgreSQL for development and test +# Use SQLite/PostgreSQL/MariaDB for development and test +gem 'mysql2' gem 'pg', '~> 1.5.9' gem 'sqlite3' @@ -55,6 +56,8 @@ group :development, :test do gem 'annotaterb' gem 'rdoc', '>= 6.6.3.1' + + gem 'dotenv' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index f7642d91..2eff1495 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,6 +96,7 @@ GEM date (3.4.1) diff-lcs (1.5.1) docile (1.4.1) + dotenv (3.1.8) drb (2.2.3) dry-cli (1.2.0) erubi (1.13.0) @@ -161,6 +162,7 @@ GEM mini_portile2 (2.8.9) minitest (5.25.5) mutex_m (0.3.0) + mysql2 (0.5.6) net-imap (0.5.8) date net-protocol @@ -369,10 +371,12 @@ PLATFORMS DEPENDENCIES annotaterb bcrypt + dotenv factory_bot_rails (~> 6.2.0) generator_spec image_processing (~> 1.12.2) maglevcms! + mysql2 nokogiri (>= 1.15.6) observer ostruct diff --git a/Gemfile.rails_7_0 b/Gemfile.rails_7_0 index dbb5d106..181c31c1 100644 --- a/Gemfile.rails_7_0 +++ b/Gemfile.rails_7_0 @@ -36,6 +36,7 @@ gem 'puma' # Use SQLite/PostgreSQL for development and test gem 'pg', '~> 1.5.9' gem 'sqlite3', '~> 1.4' +gem 'mysql2', '~> 0.5.6' # Gems no longer be part of the default gems from Ruby 3.5.0 gem 'observer' @@ -45,6 +46,7 @@ gem 'bigdecimal' gem 'mutex_m' gem 'drb' gem 'fiddle' +gem 'benchmark' group :development, :test do # Use SCSS for stylesheets @@ -60,6 +62,10 @@ group :development, :test do gem 'generator_spec' gem 'nokogiri', '>= 1.13.10' + + gem 'dotenv' + + gem 'database_cleaner-active_record' end group :test do diff --git a/Gemfile.rails_7_0.lock b/Gemfile.rails_7_0.lock index 10f4bf07..6d838d75 100644 --- a/Gemfile.rails_7_0.lock +++ b/Gemfile.rails_7_0.lock @@ -80,13 +80,19 @@ GEM ast (2.4.2) base64 (0.2.0) bcrypt (3.1.20) + benchmark (0.4.1) bigdecimal (3.1.9) builder (3.3.0) concurrent-ruby (1.3.4) crass (1.0.6) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) date (3.4.0) diff-lcs (1.5.1) docile (1.4.1) + dotenv (3.1.8) drb (2.2.1) dry-cli (1.2.0) erubi (1.13.0) @@ -141,6 +147,7 @@ GEM mini_portile2 (2.8.9) minitest (5.25.1) mutex_m (0.3.0) + mysql2 (0.5.6) net-imap (0.5.1) date net-protocol @@ -302,7 +309,10 @@ PLATFORMS DEPENDENCIES base64 bcrypt + benchmark bigdecimal + database_cleaner-active_record + dotenv drb factory_bot_rails (~> 6.2.0) fiddle @@ -311,6 +321,7 @@ DEPENDENCIES maglevcms! mini_magick (~> 4.11) mutex_m + mysql2 (~> 0.5.6) nokogiri (>= 1.13.10) observer ostruct diff --git a/Gemfile.rails_7_2 b/Gemfile.rails_7_2 index 62210536..01087ad6 100644 --- a/Gemfile.rails_7_2 +++ b/Gemfile.rails_7_2 @@ -39,6 +39,7 @@ gem 'puma' # Use SQLite/PostgreSQL for development and test gem 'pg', '~> 1.5.9' gem 'sqlite3' +gem 'mysql2', '~> 0.5.6' # Gems no longer be part of the default gems from Ruby 3.5.0 gem 'observer' @@ -60,6 +61,10 @@ group :development, :test do gem 'nokogiri', '>= 1.15.6' gem 'rdoc', '>= 6.6.3.1' + + gem 'dotenv' + + gem 'database_cleaner-active_record' end group :test do diff --git a/Gemfile.rails_7_2.lock b/Gemfile.rails_7_2.lock index e094a6ea..9ffc4d76 100644 --- a/Gemfile.rails_7_2.lock +++ b/Gemfile.rails_7_2.lock @@ -92,9 +92,14 @@ GEM concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) date (3.4.0) diff-lcs (1.5.1) docile (1.4.1) + dotenv (3.1.8) drb (2.2.1) dry-cli (1.2.0) erubi (1.13.0) @@ -151,6 +156,7 @@ GEM mini_portile2 (2.8.9) minitest (5.25.2) mutex_m (0.3.0) + mysql2 (0.5.6) net-imap (0.5.1) date net-protocol @@ -343,10 +349,13 @@ PLATFORMS DEPENDENCIES bcrypt + database_cleaner-active_record + dotenv factory_bot_rails (~> 6.2.0) generator_spec image_processing (~> 1.12.2) maglevcms! + mysql2 (~> 0.5.6) nokogiri (>= 1.15.6) observer ostruct diff --git a/app/controllers/maglev/api/page_translations_controller.rb b/app/controllers/maglev/api/page_translations_controller.rb new file mode 100644 index 00000000..2254710c --- /dev/null +++ b/app/controllers/maglev/api/page_translations_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Maglev + module Api + class PageTranslationsController < ::Maglev::ApiController + before_action :set_page + + def index + translations = @page.translations_for(:sections, params[:locale]) + render json: { translated: translations&.size.to_i > 0 } + end + + def create + translate_page(@page) + head :ok + end + + private + + def set_page + @page ||= resources.find(params[:page_id]) + end + + def translate_page(page) + services.translate_page.call( + page: page, + locale: params[:locale], + source_locale: maglev_site.default_locale_prefix.to_s + ) + end + + def resources + ::Maglev::Page + end + end + end +end diff --git a/app/frontend/editor/assets/remixicons/ri-translate.svg b/app/frontend/editor/assets/remixicons/ri-translate.svg new file mode 100644 index 00000000..3e1cd5e0 --- /dev/null +++ b/app/frontend/editor/assets/remixicons/ri-translate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/editor/components/dynamic-form/dynamic-input.vue b/app/frontend/editor/components/dynamic-form/dynamic-input.vue index cdead8eb..7f16d5b3 100644 --- a/app/frontend/editor/components/dynamic-form/dynamic-input.vue +++ b/app/frontend/editor/components/dynamic-form/dynamic-input.vue @@ -74,6 +74,7 @@ :name="setting.id" v-model="inputValue" :selectOptions="options.selectOptions" + :i18nScope="i18nScope" v-if="setting.type == 'select'" /> - {{ blockType.name }} + {{ blockTypeLabel(blockType) }} @@ -56,6 +56,9 @@ export default { ) else return this.currentSectionDefinition.blocks }, + currentBlockTypesI18nScope() { + return `${this.currentSectionI18nScope}.blocks.types` + } }, methods: { ...mapActions(['addSectionBlock']), @@ -63,6 +66,9 @@ export default { this.addSectionBlock({ blockType }) this.$refs.dropdown.close() }, + blockTypeLabel(blockType) { + return this.$st(`${this.currentBlockTypesI18nScope}.${blockType.type}`) ?? blockType.name + } }, } diff --git a/app/frontend/editor/components/section-pane/block-tree/new-nested-block-button.vue b/app/frontend/editor/components/section-pane/block-tree/new-nested-block-button.vue index 0057df6d..235b11b9 100644 --- a/app/frontend/editor/components/section-pane/block-tree/new-nested-block-button.vue +++ b/app/frontend/editor/components/section-pane/block-tree/new-nested-block-button.vue @@ -15,10 +15,10 @@ @@ -49,6 +49,9 @@ export default { (block) => this.accept.indexOf(block.type) !== -1, ) }, + currentBlockTypesI18nScope() { + return `${this.currentSectionI18nScope}.blocks.types` + } }, methods: { ...mapActions(['addSectionBlock']), @@ -62,6 +65,9 @@ export default { this.addSectionBlock({ blockType, parentId: this.parentId }) this.$refs.dropdown.close() }, + blockTypeLabel(blockType) { + return this.$st(`${this.currentBlockTypesI18nScope}.${blockType.type}`) ?? blockType.name + }, }, } diff --git a/app/frontend/editor/components/sidebar-nav/index.vue b/app/frontend/editor/components/sidebar-nav/index.vue index b75de675..d9b6d495 100644 --- a/app/frontend/editor/components/sidebar-nav/index.vue +++ b/app/frontend/editor/components/sidebar-nav/index.vue @@ -23,6 +23,14 @@ :tooltipMessage="$t('sidebarNav.managePageSectionsTooltip')" /> +
  • + +
  • import ImageLibrary from '@/components/image-library/index.vue' +import TranslateDialog from '@/components/translate-dialog/index.vue' import SidebarNavLink from './link.vue' export default { name: 'SidebarNav', components: { - SidebarNavLink + SidebarNavLink, + TranslateDialog }, computed: { hasStyle() { @@ -85,6 +95,9 @@ export default { isEditPageActive() { return this.$route.name === 'editPageSettings' }, + isTranslatable() { + return this.currentSectionList.length === 0 && (this.currentLocale !== this.currentDefaultLocale) + }, leaveEditorUrl() { return window.leaveUrl }, @@ -97,6 +110,12 @@ export default { props: { modalClass: 'w-216' }, }) }, + openTranslateModal() { + this.openModal({ + title: this.$t('translateDialog.title'), + component: TranslateDialog, + }) + } }, } diff --git a/app/frontend/editor/components/translate-dialog/index.vue b/app/frontend/editor/components/translate-dialog/index.vue new file mode 100644 index 00000000..d01678f7 --- /dev/null +++ b/app/frontend/editor/components/translate-dialog/index.vue @@ -0,0 +1,94 @@ + + + \ No newline at end of file diff --git a/app/frontend/editor/locales/editor.en.json b/app/frontend/editor/locales/editor.en.json index 2608d739..f3d0991f 100644 --- a/app/frontend/editor/locales/editor.en.json +++ b/app/frontend/editor/locales/editor.en.json @@ -25,6 +25,7 @@ "listPagesTooltip": "Manage the pages of your site", "managePageSectionsTooltip": "Re-order / delete the sections of the page", "editStyleTooltip": "Change the style of your site", + "translateTooltip": "Translate the page", "openImageLibraryTooltip": "Open the gallery of images", "leaveEditorTooltip": "Back to the main app" }, @@ -35,7 +36,7 @@ "withoutLocale": "Your page is blank", "withLocale": "Your page in %{localeName} is blank" }, - "message": "Please add the first section by clicking on the {icon} button in the left sidebar." + "message": "Please add the first section by clicking on the {icon} button or translate the whole page by clicking on the {translateIcon} button." } }, "page": { @@ -225,6 +226,16 @@ "emptyLabel": "No items found" } }, + "translateDialog": { + "title": "Translate the current page", + "description": "This page is not translated into %{locale} yet. Do you want to translate it now from %{sourceLocale}?", + "submitButton": { + "default": "Translate", + "inProgress": "Translating...", + "success": "Translated!" + }, + "cancelButton": "Cancel" + }, "pagination": { "defaultLabel": "%{start} - %{end} of %{totalItems} items", "defaultNoItems": "None" @@ -247,6 +258,13 @@ } }, "support": { + "locales": { + "en": "English", + "es": "Spanish", + "fr": "French", + "pt-BR": "Portuguese", + "ar": "Arabic" + }, "human": { "storageUnits": { "format": "%{number} %{unit}", diff --git a/app/frontend/editor/mixins/global.js b/app/frontend/editor/mixins/global.js index 3f4347f4..62488ed7 100644 --- a/app/frontend/editor/mixins/global.js +++ b/app/frontend/editor/mixins/global.js @@ -28,6 +28,9 @@ Vue.mixin({ currentLocale() { return this.$store.state.locale }, + currentDefaultLocale() { + return this.currentSite.locales[0].prefix + }, currentPage() { return this.$store.state.page }, diff --git a/app/frontend/editor/services/page.js b/app/frontend/editor/services/page.js index 6f63df94..6159f318 100644 --- a/app/frontend/editor/services/page.js +++ b/app/frontend/editor/services/page.js @@ -92,6 +92,16 @@ export default (api) => ({ return api.post(`/pages/${id}/clones`, {}).then(({ data }) => data) }, + translate: (id, locale) => { + console.log('[PageService] Translating page #', id) + return api.post(`/pages/${id}/translations`, { locale }).then(({ data }) => data) + }, + + isTranslated: (id, locale) => { + console.log('[PageService] Checking if page is translated #', id, locale) + return api.get(`/pages/${id}/translations`, { params: { locale } }).then(({ data }) => data.translated) + }, + destroy: (id) => { console.log('[PageService] Destroying page #', id) return api.destroy(`/pages/${id}`) diff --git a/app/frontend/editor/views/page-preview.vue b/app/frontend/editor/views/page-preview.vue index beae6386..97ae6319 100644 --- a/app/frontend/editor/views/page-preview.vue +++ b/app/frontend/editor/views/page-preview.vue @@ -53,7 +53,7 @@ @@ -61,13 +61,20 @@ + @@ -105,11 +112,6 @@ export default { numberOfLocales() { return this.currentSite.locales.length }, - currentLocaleName() { - return this.currentSite.locales.find( - (locale) => locale.prefix === this.currentLocale, - ).label - }, deviceClass() { switch (this.device) { case 'mobile': diff --git a/app/models/concerns/maglev/translatable.rb b/app/models/concerns/maglev/translatable.rb index d5b89961..03f33d7d 100644 --- a/app/models/concerns/maglev/translatable.rb +++ b/app/models/concerns/maglev/translatable.rb @@ -7,21 +7,40 @@ module Translatable class UnavailableLocaleError < RuntimeError; end extend ActiveSupport::Concern - def translations_for(attr) - public_send("#{attr}_translations") + def translations_for(attr, locale = nil) + # With MySQL, there is no default value for JSON columns, so we need to check for nil + translations = public_send("#{attr}_translations").presence || {} + locale.present? ? translations[locale.to_s] : translations end def translate_attr_in(attr, locale, source_locale) translations_for(attr)[locale.to_s] ||= translations_for(attr)[source_locale.to_s] end + # rubocop:disable Metrics/BlockLength class_methods do def order_by_translated(attr, direction) - order(Arel.sql("#{attr}_translations->>'#{Maglev::I18n.current_locale}'") => direction) + order(translated_arel_attribute(attr, Maglev::I18n.current_locale) => direction) + end + + def translated_arel_attribute(attr, locale) + return Arel.sql("#{attr}_translations->>'#{locale}'") unless mysql? + + # MySQL and MariaDB JSON support 🀬🀬🀬 + # Note: doesn't work with Rails 7.0.x + json_extract = Arel::Nodes::NamedFunction.new( + 'json_extract', + [Arel::Nodes::SqlLiteral.new("#{attr}_translations"), Arel::Nodes.build_quoted("$.#{locale}")] + ) + Arel::Nodes::NamedFunction.new('json_unquote', [json_extract]) end def translates(*attributes, presence: false) - attributes.each { |attr| setup_accessors(attr) } + attributes.each do |attr| + # MariaDB doesn't support native JSON columns (longtext instead), we need to force it. + attribute("#{attr}_translations", :json) if respond_to?(:attribute) + setup_accessors(attr) + end add_presence_validator(attributes) if presence end @@ -37,12 +56,14 @@ def add_presence_validator(attributes) def setup_accessors(attr) define_method("#{attr}=") do |value| - public_send("#{attr}_translations=", translations_for(attr).merge(Maglev::I18n.current_locale => value)) + public_send("#{attr}_translations=", + translations_for(attr).merge(Maglev::I18n.current_locale.to_s => value)) end define_method(attr) { translations_for(attr)[Maglev::I18n.current_locale.to_s] } define_method("default_#{attr}") { translations_for(attr)[Maglev::I18n.default_locale.to_s] } end end + # rubocop:enable Metrics/BlockLength end end diff --git a/app/models/maglev/application_record.rb b/app/models/maglev/application_record.rb index f6c87688..5422b446 100644 --- a/app/models/maglev/application_record.rb +++ b/app/models/maglev/application_record.rb @@ -3,5 +3,15 @@ module Maglev class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + + def self.mysql? + connection.adapter_name.downcase == 'mysql2' + end + + private + + def mysql? + self.class.mysql? + end end end diff --git a/app/models/maglev/page/search_concern.rb b/app/models/maglev/page/search_concern.rb index 865201a4..16fd0158 100644 --- a/app/models/maglev/page/search_concern.rb +++ b/app/models/maglev/page/search_concern.rb @@ -32,9 +32,7 @@ def search_path_clause(query, locale) end def search_title_node(locale) - Arel::Nodes::InfixOperation.new('->>', - arel_table[:title_translations], - Arel::Nodes.build_quoted(locale)) + translated_arel_attribute(:title, locale) end end end diff --git a/app/models/maglev/site.rb b/app/models/maglev/site.rb index 778e7735..18539b1f 100644 --- a/app/models/maglev/site.rb +++ b/app/models/maglev/site.rb @@ -20,6 +20,10 @@ class Site < ApplicationRecord include Maglev::SectionsConcern include Maglev::Translatable + ## force JSON columns for MariaDB ## + attribute :style, :json + attribute :sections_translations, :json + ## translations ## translates :sections diff --git a/app/models/maglev/site/locales_concern.rb b/app/models/maglev/site/locales_concern.rb index 13b1d484..9b88722d 100644 --- a/app/models/maglev/site/locales_concern.rb +++ b/app/models/maglev/site/locales_concern.rb @@ -5,12 +5,8 @@ module Maglev::Site::LocalesConcern extend ActiveSupport::Concern included do - ## serializers ## - if Rails::VERSION::MAJOR >= 8 || (Rails::VERSION::MAJOR >= 7 && Rails::VERSION::MINOR.positive?) - serialize :locales, coder: LocalesSerializer - else - serialize :locales, LocalesSerializer - end + ## custom column type ## + attribute :locales, :maglev_locales ## validation ## validates :locales, 'maglev/collection': true, length: { minimum: 1 } @@ -42,15 +38,5 @@ def each_locale end end end - - class LocalesSerializer - def self.dump(array) - (array || []).map(&:as_json) - end - - def self.load(array) - (array || []).map { |attributes| Maglev::Site::Locale.new(**attributes.symbolize_keys) } - end - end end # rubocop:enable Style/ClassAndModuleChildren diff --git a/app/services/maglev/app_container.rb b/app/services/maglev/app_container.rb index 9bbea5e9..12ce33f7 100644 --- a/app/services/maglev/app_container.rb +++ b/app/services/maglev/app_container.rb @@ -42,6 +42,7 @@ class AppContainer fetch_collection_items get_page_fullpath] dependency :get_page_section_names, class: Maglev::GetPageSectionNames, depends_on: :fetch_theme dependency :clone_page, class: Maglev::ClonePage, depends_on: :fetch_site + dependency :translate_page, class: Maglev::TranslatePage, depends_on: %i[fetch_site fetch_theme] dependency :persist_page, class: Maglev::PersistPage, depends_on: %i[fetch_theme] def call diff --git a/app/services/maglev/fetch_section_screenshot_url.rb b/app/services/maglev/fetch_section_screenshot_url.rb index e621f736..d634b9c7 100644 --- a/app/services/maglev/fetch_section_screenshot_url.rb +++ b/app/services/maglev/fetch_section_screenshot_url.rb @@ -8,7 +8,18 @@ class FetchSectionScreenshotUrl argument :section def call - fetch_section_screenshot_path.call(section: section) + "?#{section.screenshot_timestamp}" + screenshot_path = fetch_section_screenshot_path.call(section: section) + query_string + asset_host ? URI.join(asset_host, screenshot_path).to_s : screenshot_path + end + + private + + def asset_host + Rails.application.config.asset_host + end + + def query_string + "?#{section.screenshot_timestamp}" end end end diff --git a/app/services/maglev/fetch_style.rb b/app/services/maglev/fetch_style.rb index 25047c7a..093a2a66 100644 --- a/app/services/maglev/fetch_style.rb +++ b/app/services/maglev/fetch_style.rb @@ -31,7 +31,7 @@ def build_style_value(setting) end def custom_value(setting) - value = site.style.find { |local_value| local_value['id'] == setting.id } + value = (site.style || []).find { |local_value| local_value['id'] == setting.id } value && value['type'] == setting.type ? value['value'] : setting.default end end diff --git a/app/services/maglev/translate_page.rb b/app/services/maglev/translate_page.rb new file mode 100644 index 00000000..839fe752 --- /dev/null +++ b/app/services/maglev/translate_page.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +module Maglev + # Translate a page into a given locale from a source locale. + # This is a fake service that only copies the page attributes. + class TranslatePage + include Injectable + + dependency :fetch_site + dependency :fetch_theme + + argument :page + argument :locale + argument :source_locale + + def call + return nil unless page.persisted? + + @translations = {} + + translate_all! + + persist_changes! + + page + end + + protected + + def site + @site ||= fetch_site.call + end + + def theme + @theme ||= fetch_theme.call + end + + # this is a third-step process because we need to group all the translations to process + # in order to process them in parallel + def translate_all! + # 1. replace all the text to translatewith placeholders + prepare_translate_page_attributes + prepare_translate_all_sections # sections from page and site + + # 2. Translate all the placeholders in parallel + async_translate + + # 3. replace the placeholders with the actual translations that has been processed in parallel + replace_translations_in_page_attributes + replace_translations_in_all_sections + end + + def persist_changes! + ActiveRecord::Base.transaction do + site.save! + page.save! + end + end + + def async_translate + tasks = @translations.map do |id, text| + Concurrent::Promises.future do + { id => translate_text(text) } + end + end + @translations = Concurrent::Promises.zip(*tasks).value!.reduce({}, :merge) + end + + def translate_text(text) + # @note: implement actual translation logic in a subclass + # by default we just add the locale to the text + text + " [#{locale.upcase}]" + end + + # ===== Apply translations ===== + + def replace_translations_in_page_attributes + %w[title seo_title meta_description og_title og_description].each do |attr| + page.translations_for(attr)[locale] = replace_translated_text(page.translations_for(attr)[locale]) + end + end + + def replace_translations_in_all_sections + [page, site].each do |source| + source.sections_translations = JSON.parse(replace_translated_text(source.sections_translations.to_json)) + end + end + + # ===== Prepare translations ===== + + def prepare_translate_page_attributes + %w[title seo_title meta_description og_title og_description].each do |attr| + prepare_translate_page_attribute(attr) + end + # og_image_url is a special case because it's a URL, not content + page.translations_for(:og_image_url)[locale] = page.translations_for(:og_image_url)[source_locale] + end + + def prepare_translate_page_attribute(attr) + page.translations_for(attr)[locale] = prepare_translate_text(page.translations_for(attr)[source_locale]) + end + + def prepare_translate_all_sections + [page, site].each do |source| + prepare_translate_sections(source) + end + end + + def prepare_translate_sections(source) + # @note no need to translate if there are no sections in the source locale + return if source.translations_for(:sections, source_locale).blank? + + # @note we don't want to overwrite existing translations + return if source.translations_for(:sections, locale).present? + + source.sections_translations[locale] = clone_array(source.sections_translations[source_locale]).tap do |sections| + sections.each { |section| prepare_translate_section(section) } + end + end + + def prepare_translate_section(section) + definition = theme.sections.find(section['type']) + prepare_translate_settings(section, definition) + prepare_translate_section_blocks(section, definition) + end + + def prepare_translate_settings(section_or_block, definition) + return if definition.blank? # happens if the section/block is not defined in the theme + + section_or_block['settings'].each do |setting| + type = definition.settings.find { |s| s.id == setting['id'] }&.type + next if type.blank? + + setting['value'] = prepare_translate_setting_value(setting['value'], type) + end + end + + def prepare_translate_section_blocks(section, definition) + section['blocks'].each do |block| + block_definition = definition.blocks.find { |b| b.type == block['type'] } + prepare_translate_settings(block, block_definition) + end + end + + def prepare_translate_setting_value(value, type) + case type + when 'text' + prepare_translate_text(value) + when 'link' + value.merge(text: prepare_translate_text(value['text'])) if value.is_a?(Hash) + when 'image' + value.merge(alt: prepare_translate_text(value['alt'])) if value.is_a?(Hash) + else + value + end + end + + # NOTE: this method is a placeholder for the actual translation logic. + def prepare_translate_text(text) + return text if text.blank? + + id = SecureRandom.uuid + @translations[id] = text + + "--#{id}--" + end + + def replace_translated_text(text) + return text if text.blank? + + text.gsub(/--([a-f0-9-]{36})--/) do |_match| + @translations[::Regexp.last_match(1)].to_json.gsub(/^\"/, '').gsub(/\"$/, '') + end + end + + def clone_array(array) + Marshal.load(Marshal.dump(array || [])) + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/types/maglev/locales_type.rb b/app/types/maglev/locales_type.rb new file mode 100644 index 00000000..1720f89b --- /dev/null +++ b/app/types/maglev/locales_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Maglev + class LocalesType < ActiveRecord::Type::Json + def deserialize(value) + (super || []).map { |attributes| Maglev::Site::Locale.new(**attributes.symbolize_keys) } + end + end +end diff --git a/app/views/maglev/api/page_translations/create.json.jbuilder b/app/views/maglev/api/page_translations/create.json.jbuilder new file mode 100644 index 00000000..54e4c551 --- /dev/null +++ b/app/views/maglev/api/page_translations/create.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial!('/maglev/api/pages/show', page: @page) diff --git a/config/initializers/active_record_types.rb b/config/initializers/active_record_types.rb new file mode 100644 index 00000000..a4f466ae --- /dev/null +++ b/config/initializers/active_record_types.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative '../../app/types/maglev/locales_type' + +ActiveRecord::Type.register(:maglev_locales, Maglev::LocalesType) diff --git a/config/initializers/migration_patches.rb b/config/initializers/migration_patches.rb new file mode 100644 index 00000000..bd1c2856 --- /dev/null +++ b/config/initializers/migration_patches.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActiveRecord + class Migration + def mysql? + connection.adapter_name.downcase == 'mysql2' + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 632bc600..9a27215d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,7 @@ resource :site, only: :show resources :pages do resources :clones, controller: :page_clones, only: :create + resources :translations, controller: :page_translations, only: %i[index create] end resources :assets resource :publication, only: %i[show create] diff --git a/db/migrate/20200831101942_create_maglev_section_content.rb b/db/migrate/20200831101942_create_maglev_section_content.rb index d41c20c0..581bcfda 100644 --- a/db/migrate/20200831101942_create_maglev_section_content.rb +++ b/db/migrate/20200831101942_create_maglev_section_content.rb @@ -3,6 +3,8 @@ def change change_table :maglev_sites do |t| if t.respond_to? :jsonb t.jsonb :sections, default: [] + elsif mysql? + t.json :sections # MySQL doesn't support default values for json columns else t.json :sections, default: [] end @@ -11,6 +13,8 @@ def change change_table :maglev_pages do |t| if t.respond_to? :jsonb t.jsonb :sections, default: [] + elsif mysql? + t.json :sections # MySQL doesn't support default values for json columns else t.json :sections, default: [] end diff --git a/db/migrate/20210819092740_switch_to_localized_page_fields.rb b/db/migrate/20210819092740_switch_to_localized_page_fields.rb index dac5ba4a..c08d21ee 100644 --- a/db/migrate/20210819092740_switch_to_localized_page_fields.rb +++ b/db/migrate/20210819092740_switch_to_localized_page_fields.rb @@ -1,12 +1,18 @@ class SwitchToLocalizedPageFields < ActiveRecord::Migration[6.1] def up - remove_columns :maglev_pages, :title, :seo_title, :meta_description - + remove_column :maglev_pages, :title if column_exists?(:maglev_pages, :title) + remove_column :maglev_pages, :seo_title if column_exists?(:maglev_pages, :seo_title) + remove_column :maglev_pages, :meta_description if column_exists?(:maglev_pages, :meta_description) + change_table :maglev_pages do |t| if t.respond_to? :jsonb t.jsonb :title_translations, default: {} t.jsonb :seo_title_translations, default: {} t.jsonb :meta_description_translations, default: {} + elsif mysql? + t.json :title_translations + t.json :seo_title_translations + t.json :meta_description_translations else t.json :title_translations, default: {} t.json :seo_title_translations, default: {} diff --git a/db/migrate/20211008064437_add_locales_to_sites.rb b/db/migrate/20211008064437_add_locales_to_sites.rb index 083642df..c2338169 100644 --- a/db/migrate/20211008064437_add_locales_to_sites.rb +++ b/db/migrate/20211008064437_add_locales_to_sites.rb @@ -3,6 +3,8 @@ def change change_table :maglev_sites do |t| if t.respond_to? :jsonb t.jsonb :locales, default: [] + elsif mysql? + t.json :locales # MySQL doesn't support default values for json columns else t.json :locales, default: [] end diff --git a/db/migrate/20211013210954_translate_section_content.rb b/db/migrate/20211013210954_translate_section_content.rb index ecdc2567..0d23333d 100644 --- a/db/migrate/20211013210954_translate_section_content.rb +++ b/db/migrate/20211013210954_translate_section_content.rb @@ -1,11 +1,13 @@ class TranslateSectionContent < ActiveRecord::Migration[6.0] def change - remove_column :maglev_sites, :sections, :jsonb, default: [] - remove_column :maglev_pages, :sections, :jsonb, default: [] + remove_column :maglev_sites, :sections, :jsonb, default: [] if column_exists?(:maglev_sites, :sections) + remove_column :maglev_pages, :sections, :jsonb, default: [] if column_exists?(:maglev_pages, :sections) change_table :maglev_sites do |t| if t.respond_to? :jsonb t.jsonb :sections_translations, default: {} + elsif mysql? + t.json :sections_translations # MySQL doesn't support default values for json columns else t.json :sections_translations, default: {} end @@ -14,6 +16,8 @@ def change change_table :maglev_pages do |t| if t.respond_to? :jsonb t.jsonb :sections_translations, default: {} + elsif mysql? + t.json :sections_translations # MySQL doesn't support default values for json columns else t.json :sections_translations, default: {} end diff --git a/db/migrate/20211203224112_add_open_graph_tags_to_pages.rb b/db/migrate/20211203224112_add_open_graph_tags_to_pages.rb index 2af44234..615f46b8 100644 --- a/db/migrate/20211203224112_add_open_graph_tags_to_pages.rb +++ b/db/migrate/20211203224112_add_open_graph_tags_to_pages.rb @@ -5,6 +5,10 @@ def change t.jsonb :og_title_translations, default: {} t.jsonb :og_description_translations, default: {} t.jsonb :og_image_url_translations, default: {} + elsif mysql? + t.json :og_title_translations + t.json :og_description_translations + t.json :og_image_url_translations else t.json :og_title_translations, default: {} t.json :og_description_translations, default: {} diff --git a/db/migrate/20220612092235_add_style_to_sites.rb b/db/migrate/20220612092235_add_style_to_sites.rb index 6a81df81..1d035c4c 100644 --- a/db/migrate/20220612092235_add_style_to_sites.rb +++ b/db/migrate/20220612092235_add_style_to_sites.rb @@ -3,6 +3,8 @@ def change change_table :maglev_sites do |t| if t.respond_to? :jsonb t.jsonb :style, default: [] + elsif mysql? + t.json :style # MySQL doesn't support default values for json columns else t.json :style, default: [] end diff --git a/lib/maglev.rb b/lib/maglev.rb index e662bc23..e5c4eaab 100644 --- a/lib/maglev.rb +++ b/lib/maglev.rb @@ -29,7 +29,7 @@ def config c.uploader = :active_storage c.site_publishable = false c.preview_host = nil - c.asset_host = Rails.application.config.action_controller.asset_host + c.asset_host = Rails.application.config.asset_host c.ui_locale = nil c.back_action = nil c.services = {} diff --git a/maglevcms.gemspec b/maglevcms.gemspec index aeade60d..9743b240 100644 --- a/maglevcms.gemspec +++ b/maglevcms.gemspec @@ -43,7 +43,7 @@ Gem::Specification.new do |spec| 'MIT-LICENSE', 'Rakefile', 'README.md' - ] + ].reject { |f| f.start_with?('db/mysql') } spec.add_dependency 'jbuilder', '< 3', '>= 2' spec.add_dependency 'kaminari', '~> 1.2.1' diff --git a/spec/dummy/app/theme/sections/headers/jumbotron.yml b/spec/dummy/app/theme/sections/headers/jumbotron.yml index fc85f5d5..a37cb3dd 100644 --- a/spec/dummy/app/theme/sections/headers/jumbotron.yml +++ b/spec/dummy/app/theme/sections/headers/jumbotron.yml @@ -20,6 +20,18 @@ settings: html: true line_break: true default: "Body" + +- label: "Alignment" + id: alignment + type: select + select_options: + - label: "Left" + value: left + - label: "Center" + value: center + - label: "Right" + value: right + # Definition of the blocks. # You can define as many types of blocks as you want. diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb index 6d2cba07..1e9b9192 100644 --- a/spec/dummy/config/boot.rb +++ b/spec/dummy/config/boot.rb @@ -4,4 +4,7 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'dotenv/load' + $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml index 761b4835..f54a2daa 100644 --- a/spec/dummy/config/database.yml +++ b/spec/dummy/config/database.yml @@ -19,6 +19,15 @@ default: &default adapter: sqlite3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 + <% elsif ENV['USE_MYSQL'] %> + adapter: mysql2 + encoding: utf8mb4 + collation: utf8mb4_unicode_ci + host: <%= ENV.fetch('MAGLEV_APP_DATABASE_HOST') { 'localhost' } %> + username: <%= ENV.fetch('MAGLEV_APP_DATABASE_USERNAME') { 'start' } %> + password: <%= ENV.fetch('MAGLEV_APP_DATABASE_PASSWORD') { 'start' } %> + port: <%= ENV.fetch('MAGLEV_APP_DATABASE_PORT') { '3306' } %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> <% else %> adapter: postgresql encoding: unicode diff --git a/spec/dummy/config/locales/en.yml b/spec/dummy/config/locales/en.yml index 9d47a02d..8ce06c56 100644 --- a/spec/dummy/config/locales/en.yml +++ b/spec/dummy/config/locales/en.yml @@ -34,3 +34,22 @@ en: lang: en: "English" fr: "FranΓ§ais" + + + maglev: + themes: + simple: + sections: + navbar: + blocks: + label: "Menu πŸ”" + types: + menu_item: "Menu item 🀬" + alt_menu_item: "Alt Menu item πŸͺ" + jumbotron: + settings: + alignment: "Alignment πŸ“" + alignment_options: + left: "Left πŸ”" + center: "Center πŸͺ" + right: "Right πŸ₯—" \ No newline at end of file diff --git a/spec/dummy/db/schema.mariadb.rb b/spec/dummy/db/schema.mariadb.rb new file mode 100644 index 00000000..52322c20 --- /dev/null +++ b/spec/dummy/db/schema.mariadb.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 20_220_612_092_235) do + create_table 'accounts', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'email' + t.string 'password_digest' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'active_storage_attachments', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.bigint 'record_id', null: false + t.bigint 'blob_id', null: false + t.datetime 'created_at', precision: nil, null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', + unique: true + end + + create_table 'active_storage_blobs', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', precision: nil, null: false + t.string 'service_name', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + end + + create_table 'active_storage_variant_records', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', + force: :cascade do |t| + t.bigint 'blob_id', null: false + t.string 'variation_digest', null: false + t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true + end + + create_table 'maglev_assets', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'filename' + t.string 'content_type' + t.integer 'width' + t.integer 'height' + t.integer 'byte_size' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'maglev_page_paths', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.bigint 'maglev_page_id' + t.string 'locale', null: false + t.string 'value', null: false + t.boolean 'canonical', default: true + t.index %w[canonical locale value], name: 'canonical_speed' + t.index %w[canonical maglev_page_id locale], name: 'scoped_canonical_speed' + t.index ['maglev_page_id'], name: 'index_maglev_page_paths_on_maglev_page_id' + end + + create_table 'maglev_pages', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.boolean 'visible', default: true + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.text 'title_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.text 'seo_title_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.text 'meta_description_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.text 'sections_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.integer 'lock_version' + t.text 'og_title_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.text 'og_description_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.text 'og_image_url_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.check_constraint 'json_valid(`meta_description_translations`)', name: 'meta_description_translations' + t.check_constraint 'json_valid(`og_description_translations`)', name: 'og_description_translations' + t.check_constraint 'json_valid(`og_image_url_translations`)', name: 'og_image_url_translations' + t.check_constraint 'json_valid(`og_title_translations`)', name: 'og_title_translations' + t.check_constraint 'json_valid(`sections_translations`)', name: 'sections_translations' + t.check_constraint 'json_valid(`seo_title_translations`)', name: 'seo_title_translations' + t.check_constraint 'json_valid(`title_translations`)', name: 'title_translations' + end + + create_table 'maglev_sites', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.text 'locales', size: :long, default: '[]', collation: 'utf8mb4_bin' + t.text 'sections_translations', size: :long, default: '{}', collation: 'utf8mb4_bin' + t.integer 'lock_version' + t.text 'style', size: :long, default: '[]', collation: 'utf8mb4_bin' + t.check_constraint 'json_valid(`locales`)', name: 'locales' + t.check_constraint 'json_valid(`sections_translations`)', name: 'sections_translations' + t.check_constraint 'json_valid(`style`)', name: 'style' + end + + create_table 'products', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.string 'sku' + t.float 'price' + t.boolean 'sold_out', default: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' + add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id' +end diff --git a/spec/dummy/db/schema.mysql.rb b/spec/dummy/db/schema.mysql.rb new file mode 100644 index 00000000..e4025c2f --- /dev/null +++ b/spec/dummy/db/schema.mysql.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 20_220_612_092_235) do + create_table 'accounts', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'email' + t.string 'password_digest' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'active_storage_attachments', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.bigint 'record_id', null: false + t.bigint 'blob_id', null: false + t.datetime 'created_at', precision: nil, null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', + unique: true + end + + create_table 'active_storage_blobs', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', precision: nil, null: false + t.string 'service_name', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + end + + create_table 'active_storage_variant_records', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', + force: :cascade do |t| + t.bigint 'blob_id', null: false + t.string 'variation_digest', null: false + t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true + end + + create_table 'maglev_assets', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'filename' + t.string 'content_type' + t.integer 'width' + t.integer 'height' + t.integer 'byte_size' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'maglev_page_paths', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.bigint 'maglev_page_id' + t.string 'locale', null: false + t.string 'value', null: false + t.boolean 'canonical', default: true + t.index %w[canonical locale value], name: 'canonical_speed' + t.index %w[canonical maglev_page_id locale], name: 'scoped_canonical_speed' + t.index ['maglev_page_id'], name: 'index_maglev_page_paths_on_maglev_page_id' + end + + create_table 'maglev_pages', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.boolean 'visible', default: true + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.json 'title_translations' + t.json 'seo_title_translations' + t.json 'meta_description_translations' + t.json 'sections_translations' + t.integer 'lock_version' + t.json 'og_title_translations' + t.json 'og_description_translations' + t.json 'og_image_url_translations' + end + + create_table 'maglev_sites', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.json 'locales' + t.json 'sections_translations' + t.integer 'lock_version' + t.json 'style' + end + + create_table 'products', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.string 'sku' + t.float 'price' + t.boolean 'sold_out', default: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' + add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id' +end diff --git a/spec/legacy_dummy/config/boot.rb b/spec/legacy_dummy/config/boot.rb index 6d2cba07..1e9b9192 100644 --- a/spec/legacy_dummy/config/boot.rb +++ b/spec/legacy_dummy/config/boot.rb @@ -4,4 +4,7 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'dotenv/load' + $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) diff --git a/spec/legacy_dummy/config/database.yml b/spec/legacy_dummy/config/database.yml index 3dbd2eb2..ecb4bf34 100644 --- a/spec/legacy_dummy/config/database.yml +++ b/spec/legacy_dummy/config/database.yml @@ -19,6 +19,15 @@ default: &default adapter: sqlite3 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 + <% elsif ENV['USE_MYSQL'] %> + adapter: mysql2 + encoding: utf8mb4 + collation: utf8mb4_unicode_ci + host: <%= ENV.fetch('MAGLEV_APP_DATABASE_HOST') { 'localhost' } %> + username: <%= ENV.fetch('MAGLEV_APP_DATABASE_USERNAME') { 'start' } %> + password: <%= ENV.fetch('MAGLEV_APP_DATABASE_PASSWORD') { 'start' } %> + port: <%= ENV.fetch('MAGLEV_APP_DATABASE_PORT') { '3306' } %> + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> <% else %> adapter: postgresql encoding: unicode diff --git a/spec/legacy_dummy/config/environments/test.rb b/spec/legacy_dummy/config/environments/test.rb index 151eab39..e361f3da 100644 --- a/spec/legacy_dummy/config/environments/test.rb +++ b/spec/legacy_dummy/config/environments/test.rb @@ -51,7 +51,4 @@ # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr - - # Raises error for missing translations. - # config.action_view.raise_on_missing_translations = true end diff --git a/spec/legacy_dummy/db/schema.mariadb.rb b/spec/legacy_dummy/db/schema.mariadb.rb new file mode 100644 index 00000000..be31467a --- /dev/null +++ b/spec/legacy_dummy/db/schema.mariadb.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 20_220_612_092_235) do + create_table 'accounts', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'email' + t.string 'password_digest' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'active_storage_attachments', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.bigint 'record_id', null: false + t.bigint 'blob_id', null: false + t.datetime 'created_at', precision: nil, null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', + unique: true + end + + create_table 'active_storage_blobs', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', precision: nil, null: false + t.string 'service_name', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + end + + create_table 'active_storage_variant_records', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', + force: :cascade do |t| + t.bigint 'blob_id', null: false + t.string 'variation_digest', null: false + t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true + end + + create_table 'maglev_assets', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'filename' + t.string 'content_type' + t.integer 'width' + t.integer 'height' + t.integer 'byte_size' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'maglev_page_paths', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.bigint 'maglev_page_id' + t.string 'locale', null: false + t.string 'value', null: false + t.boolean 'canonical', default: true + t.index %w[canonical locale value], name: 'canonical_speed' + t.index %w[canonical maglev_page_id locale], name: 'scoped_canonical_speed' + t.index ['maglev_page_id'], name: 'index_maglev_page_paths_on_maglev_page_id' + end + + create_table 'maglev_pages', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.boolean 'visible', default: true + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.text 'title_translations', size: :long, collation: 'utf8mb4_bin' + t.text 'seo_title_translations', size: :long, collation: 'utf8mb4_bin' + t.text 'meta_description_translations', size: :long, collation: 'utf8mb4_bin' + t.text 'sections_translations', size: :long, collation: 'utf8mb4_bin' + t.integer 'lock_version' + t.text 'og_title_translations', size: :long, collation: 'utf8mb4_bin' + t.text 'og_description_translations', size: :long, collation: 'utf8mb4_bin' + t.text 'og_image_url_translations', size: :long, collation: 'utf8mb4_bin' + t.check_constraint 'json_valid(`meta_description_translations`)', name: 'meta_description_translations' + t.check_constraint 'json_valid(`og_description_translations`)', name: 'og_description_translations' + t.check_constraint 'json_valid(`og_image_url_translations`)', name: 'og_image_url_translations' + t.check_constraint 'json_valid(`og_title_translations`)', name: 'og_title_translations' + t.check_constraint 'json_valid(`sections_translations`)', name: 'sections_translations' + t.check_constraint 'json_valid(`seo_title_translations`)', name: 'seo_title_translations' + t.check_constraint 'json_valid(`title_translations`)', name: 'title_translations' + end + + create_table 'maglev_sites', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.text 'locales', size: :long, collation: 'utf8mb4_bin' + t.text 'sections_translations', size: :long, collation: 'utf8mb4_bin' + t.integer 'lock_version' + t.text 'style', size: :long, collation: 'utf8mb4_bin' + t.check_constraint 'json_valid(`locales`)', name: 'locales' + t.check_constraint 'json_valid(`sections_translations`)', name: 'sections_translations' + t.check_constraint 'json_valid(`style`)', name: 'style' + end + + create_table 'products', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.string 'sku' + t.float 'price' + t.boolean 'sold_out', default: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' + add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id' +end diff --git a/spec/legacy_dummy/db/schema.mysql.rb b/spec/legacy_dummy/db/schema.mysql.rb new file mode 100644 index 00000000..c1fdd95d --- /dev/null +++ b/spec/legacy_dummy/db/schema.mysql.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 20_220_612_092_235) do + create_table 'accounts', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'email' + t.string 'password_digest' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'active_storage_attachments', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name', null: false + t.string 'record_type', null: false + t.bigint 'record_id', null: false + t.bigint 'blob_id', null: false + t.datetime 'created_at', precision: nil, null: false + t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' + t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', + unique: true + end + + create_table 'active_storage_blobs', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'key', null: false + t.string 'filename', null: false + t.string 'content_type' + t.text 'metadata' + t.bigint 'byte_size', null: false + t.string 'checksum', null: false + t.datetime 'created_at', precision: nil, null: false + t.string 'service_name', null: false + t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + end + + create_table 'active_storage_variant_records', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', + force: :cascade do |t| + t.bigint 'blob_id', null: false + t.string 'variation_digest', null: false + t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true + end + + create_table 'maglev_assets', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'filename' + t.string 'content_type' + t.integer 'width' + t.integer 'height' + t.integer 'byte_size' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + create_table 'maglev_page_paths', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.bigint 'maglev_page_id' + t.string 'locale', null: false + t.string 'value', null: false + t.boolean 'canonical', default: true + t.index %w[canonical locale value], name: 'canonical_speed' + t.index %w[canonical maglev_page_id locale], name: 'scoped_canonical_speed' + t.index ['maglev_page_id'], name: 'index_maglev_page_paths_on_maglev_page_id' + end + + create_table 'maglev_pages', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.boolean 'visible', default: true + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.json 'title_translations' + t.json 'seo_title_translations' + t.json 'meta_description_translations' + t.json 'sections_translations' + t.integer 'lock_version' + t.json 'og_title_translations' + t.json 'og_description_translations' + t.json 'og_image_url_translations' + end + + create_table 'maglev_sites', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.json 'locales' + t.json 'sections_translations' + t.integer 'lock_version' + t.json 'style' + end + + create_table 'products', charset: 'utf8mb4', collation: 'utf8mb4_unicode_ci', force: :cascade do |t| + t.string 'name' + t.string 'sku' + t.float 'price' + t.boolean 'sold_out', default: false + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + end + + add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' + add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id' +end diff --git a/spec/models/maglev/page_spec.rb b/spec/models/maglev/page_spec.rb index 96fe9bf7..6883a70e 100644 --- a/spec/models/maglev/page_spec.rb +++ b/spec/models/maglev/page_spec.rb @@ -7,6 +7,21 @@ expect(build(:page)).to be_valid end + describe 'JSON translation fields initialization' do + it 'initializes translation fields with empty hashes when nil' do + # Create a page without setting translation fields (simulating MySQL behavior) + page = described_class.new(title_translations: nil) + expect(page.title).to eq nil + end + + it 'preserves existing translation values when not nil' do + existing_translations = { en: 'Test Title' } + page = described_class.new(title_translations: existing_translations) + + expect(page.title_translations).to eq(existing_translations.stringify_keys) + end + end + describe '#index?' do subject { page.index? } diff --git a/spec/services/maglev/fetch_section_screenshot_url_spec.rb b/spec/services/maglev/fetch_section_screenshot_url_spec.rb index b9a08783..72fc7a14 100644 --- a/spec/services/maglev/fetch_section_screenshot_url_spec.rb +++ b/spec/services/maglev/fetch_section_screenshot_url_spec.rb @@ -13,4 +13,14 @@ it 'returns the url to the screenshot of the section' do expect(subject).to eq '/theme/jumbotron.png?42' end + + context 'when there is a Rails asset host set' do + before do + allow(Rails.application.config).to receive(:asset_host).and_return('https://assets.maglev.local') + end + + it 'uses the Rails asset host to build the url' do + expect(subject).to eq 'https://assets.maglev.local/theme/jumbotron.png?42' + end + end end diff --git a/spec/services/maglev/translate_page_spec.rb b/spec/services/maglev/translate_page_spec.rb new file mode 100644 index 00000000..769f9669 --- /dev/null +++ b/spec/services/maglev/translate_page_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Maglev::TranslatePage do + subject { service.call(page: page, locale: 'fr', source_locale: 'en') } + + let(:site) { create(:site) } + let(:fetch_site) { double('FetchSite', call: site) } + let(:fetch_theme) { double('FetchTheme', call: build(:theme)) } + let(:service) { described_class.new(fetch_site: fetch_site, fetch_theme: fetch_theme) } + let!(:page) { create(:page, :with_navbar) } + + context 'When the site has un-translated sections' do + let(:site) { create(:site, :with_navbar) } + + it 'translates the site attributes into the given locale' do + subject + expect(site.sections_translations.dig('fr', 0, 'blocks', 0, 'settings', 0, 'value')).to eq 'Home [FR]' + end + end + + context 'When the site has translated sections' do + let(:site) do + create(:site, :with_navbar, + sections_translations: { fr: [ + { type: 'navbar', + blocks: [{ type: 'menu_item', settings: [{ id: 'label', value: 'Accueil' }] }] } + ] }) + end + + it 'doesn\'t touch the existing translations' do + subject + expect(site.sections_translations.dig('fr', 0, 'blocks', 0, 'settings', 0, 'value')).to eq 'Accueil' + end + end + + # rubocop:disable Style/StringHashKeys + it 'translates the page attributes into the given locale' do + expect(subject.title_translations).to eq({ 'en' => 'Home', 'fr' => 'Home [FR]' }) + expect(subject.sections_translations.dig('fr', 1, 'settings', 0, 'value')).to eq 'Hello world [FR]' + end + # rubocop:enable Style/StringHashKeys + + context 'The translation service returns unscaped HTML' do + before do + allow(service).to receive(:translate_text).and_return('Hello world Google') + end + + it 'escapes the HTML' do + expect(subject.sections_translations.dig('fr', 1, 'settings', 0, 'value')).to eq 'Hello world Google' + end + end +end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb new file mode 100644 index 00000000..2081f72e --- /dev/null +++ b/spec/support/database_cleaner.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +begin + require 'database_cleaner/active_record' + + RSpec.configure do |config| + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + end + + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end + end + end +rescue LoadError + # DatabaseCleaner is not available +end