From b2c8286e03a8a9a0360d6d5bf782670e37a95242 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Mon, 20 Oct 2025 14:57:52 -0230 Subject: [PATCH 1/8] feat(translations): implement translation management index and statistics --- .../translations_controller.rb | 51 ++++ .../translations/index.html.erb | 281 ++++++++++++++++++ config/locales/en.yml | 4 + config/locales/es.yml | 21 ++ config/locales/fr.yml | 4 + config/routes.rb | 1 + 6 files changed, 362 insertions(+) create mode 100644 app/views/better_together/translations/index.html.erb diff --git a/app/controllers/better_together/translations_controller.rb b/app/controllers/better_together/translations_controller.rb index db50debbd..2907aa459 100644 --- a/app/controllers/better_together/translations_controller.rb +++ b/app/controllers/better_together/translations_controller.rb @@ -2,6 +2,57 @@ module BetterTogether class TranslationsController < ApplicationController # rubocop:todo Style/Documentation + def index + # Get the locale filter from params, default to 'all' + @locale_filter = params[:locale_filter] || 'all' + @available_locales = I18n.available_locales.map(&:to_s) + + # Base query for translated model types + base_query = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .order(:translatable_type) + + # Apply locale filter if specified + if @locale_filter != 'all' && @available_locales.include?(@locale_filter) + base_query = base_query.where(locale: @locale_filter) + end + + @translated_model_types = base_query.pluck(:translatable_type).uniq + + # Calculate translation statistics per locale and model type + @translation_stats = calculate_translation_stats if @translated_model_types.any? + end + + private + + def calculate_translation_stats + stats = {} + + @translated_model_types.each do |model_type| + stats[model_type] = {} + + @available_locales.each do |locale| + # Count total records and translated records for this model and locale + total_records = begin + model_type.constantize.count + rescue StandardError + 0 + end + translated_count = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_type, locale: locale) + .distinct(:translatable_id) + .count + + stats[model_type][locale] = { + total: total_records, + translated: translated_count, + percentage: total_records > 0 ? ((translated_count.to_f / total_records) * 100).round(1) : 0 + } + end + end + + stats + end + def translate content = params[:content] source_locale = params[:source_locale] diff --git a/app/views/better_together/translations/index.html.erb b/app/views/better_together/translations/index.html.erb new file mode 100644 index 000000000..09e073644 --- /dev/null +++ b/app/views/better_together/translations/index.html.erb @@ -0,0 +1,281 @@ +<% content_for :page_title, t('.title') %> + +
+
+
+

+ + <%= t('.title') %> +

+ + <% if @translated_model_types.any? %> + <% + # Get current locale filter from params + selected_locale = params[:locale_filter] || 'all' + available_locales = I18n.available_locales.map(&:to_s) + + # Group model types by namespace + grouped_models = @translated_model_types.group_by do |model_type| + parts = model_type.split('::') + parts.length > 1 ? parts[0..-2].join('::') : 'Base' + end + + # Sort groups with 'BetterTogether' first, then 'Base', then alphabetically + sorted_groups = grouped_models.sort_by do |namespace, _| + case namespace + when 'BetterTogether' then '0' + when 'Base' then 'z' + else namespace + end + end + %> + + +
+

+ + Filter by Locale +

+ +
+ + <% if selected_locale != 'all' %> + + <% end %> + + <% sorted_groups.each_with_index do |(namespace, models), group_index| %> +
+

+ + <%= namespace == 'Base' ? 'Core Models' : namespace.humanize %> + <%= models.count %> +

+ + + + + +
+ <% models.each_with_index do |model_type, model_index| %> + <% + model_class = model_type.constantize + panel_id = "#{model_type.downcase.gsub('::', '-')}-panel" + tab_id = "#{model_type.downcase.gsub('::', '-')}-tab" + is_active = group_index == 0 && model_index == 0 + + class_name = model_type.split('::').last + %> +
+
+
+

+ + <%= model_class.model_name.human %> + (<%= model_type %>) +

+ <% if selected_locale != 'all' %> + + + <%= t("locales.#{selected_locale}") %> Only + + <% end %> +
+
+ <% + translated_attributes = model_class.respond_to?(:mobility_attributes) ? model_class.mobility_attributes : [] + %> + + <% if translated_attributes.any? %> +
+
+ + Translation Values for <%= model_class.model_name.human %> +
+ +
+ + + + + + <% translated_attributes.each do |attribute| %> + + <% end %> + + + + + + + + +
+ + ID + + + Identifier + + + <%= attribute.to_s.humanize %> + + + Actions +
+ + No <%= model_class.model_name.human.downcase %> records to display. + Translation data will appear here when available. +
+
+
+ +
+
+
Translated Attributes:
+
    + <% translated_attributes.each do |attribute| %> +
  • + + + <%= attribute %> + + + <%= attribute.to_s.humanize.downcase %> + +
  • + <% end %> +
+
+ +
+
Model Information:
+
    +
  • Full Class: <%= model_type %>
  • +
  • Namespace: <%= namespace %>
  • +
  • Model Name: <%= class_name %>
  • +
  • Attributes Count: <%= translated_attributes.count %>
  • +
+ +
+ + + These attributes support multiple language translations through the Mobility gem. + +
+
+
+ <% else %> + + +
+
Model Information:
+
    +
  • Full Class: <%= model_type %>
  • +
  • Namespace: <%= namespace %>
  • +
  • Model Name: <%= class_name %>
  • +
+
+ <% end %> +
+
+
+ <% end %> +
+
+ <% end %> + <% else %> + + <% end %> +
+
+
\ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 5d73470d9..c844df679 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1489,6 +1489,10 @@ en: support: Support terms_of_service: Terms of Service title: Website Links + translations: + index: + title: Translation Management + no_translatable_content: No translatable content is available at this time block: :activerecord.models.block community: create_failed: Create failed diff --git a/config/locales/es.yml b/config/locales/es.yml index 93322ea7e..6c1c991fd 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1500,6 +1500,27 @@ es: support: Soporte terms_of_service: Términos de Servicio title: Enlaces de Sitio Web + translations: + index: + title: Gestión de Traducciones + no_translatable_content: No hay contenido traducible disponible en este momento + translations: + index: + title: Gestión de Traducciones + translate_model: "Traducir %{model}" + source_content: Contenido Fuente + source_locale: Idioma Fuente + target_locale: Idioma Destino + translated_content: Contenido Traducido + content_to_translate: Contenido a Traducir + translation_result: Resultado de la Traducción + enter_content_placeholder: Ingrese el contenido a traducir... + translation_will_appear_here: La traducción aparecerá aquí... + content_help_text: "Ingrese el contenido %{model} que necesita ser traducido" + translation_help_text: El contenido traducido aparecerá aquí después del procesamiento + translate_button: Traducir + translating_status: Traduciendo... + no_translatable_content: No hay contenido traducible disponible en este momento block: :activerecord.models.block community: create_failed: Creación fallida diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c9f886c48..612c7a0f0 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1508,6 +1508,10 @@ fr: support: Support terms_of_service: Conditions d'utilisation title: Liens de site web + translations: + index: + title: Gestion des Traductions + no_translatable_content: Aucun contenu traduisible n'est disponible pour le moment block: :activerecord.models.block community: create_failed: Échec de la création diff --git a/config/routes.rb b/config/routes.rb index e01a3f193..e8c4e3914 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -175,6 +175,7 @@ # Only logged-in users have access to the AI translation feature for now. Needs code adjustments, too. scope path: :translations do + get '/', to: 'translations#index', as: :translations post 'translate', to: 'translations#translate', as: :ai_translate end From 5ccb8a4b51e797299e599c4903e084a9ed93e8b8 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Mon, 20 Oct 2025 18:08:58 -0230 Subject: [PATCH 2/8] Refactor translation management interface - Simplified the translations index view by removing unnecessary locale filter tabs and replacing them with a more structured tab navigation system. - Introduced lazy loading for translation data by locale, model type, data type, and attribute. - Enhanced the locale switcher to display formatted locale names. - Updated locale files (English, Spanish, French) with new keys and translations for the revamped translation management features. - Added new routes for fetching translations by locale, model type, data type, and attribute. - Implemented request specs for translation management to ensure proper rendering and filtering functionality. --- .../better_together/application_controller.rb | 36 +- .../translations_controller.rb | 1078 ++++++++++++++++- .../better_together/application_helper.rb | 25 + .../translation_manager_controller.js | 29 + .../translations/_by_attribute.html.erb | 115 ++ .../translations/_by_data_type.html.erb | 112 ++ .../translations/_by_locale.html.erb | 111 ++ .../translations/_by_model_type.html.erb | 115 ++ .../translations/_overview.html.erb | 279 +++++ .../translations/_records.html.erb | 242 ++++ .../translations/index.html.erb | 353 ++---- .../better_together/_locale_switcher.html.erb | 2 +- config/locales/en.yml | 130 ++ config/locales/es.yml | 134 +- config/locales/fr.yml | 130 ++ config/routes.rb | 4 + .../better_together/translations_spec.rb | 74 ++ 17 files changed, 2664 insertions(+), 305 deletions(-) create mode 100644 app/javascript/controllers/better_together/translation_manager_controller.js create mode 100644 app/views/better_together/translations/_by_attribute.html.erb create mode 100644 app/views/better_together/translations/_by_data_type.html.erb create mode 100644 app/views/better_together/translations/_by_locale.html.erb create mode 100644 app/views/better_together/translations/_by_model_type.html.erb create mode 100644 app/views/better_together/translations/_overview.html.erb create mode 100644 app/views/better_together/translations/_records.html.erb create mode 100644 spec/requests/better_together/translations_spec.rb diff --git a/app/controllers/better_together/application_controller.rb b/app/controllers/better_together/application_controller.rb index 1f4ddac3c..cf615db3c 100644 --- a/app/controllers/better_together/application_controller.rb +++ b/app/controllers/better_together/application_controller.rb @@ -214,11 +214,14 @@ def extract_locale_from_accept_language_header end def set_locale - locale = params[:locale] || # Request parameter - session[:locale] || # Session stored locale - helpers.current_person&.locale || # Model saved configuration - extract_locale_from_accept_language_header || # Language header - browser config - I18n.default_locale # Set in your config files, english by super-default + raw_locale = params[:locale] || # Request parameter + session[:locale] || # Session stored locale + helpers.current_person&.locale || # Model saved configuration + extract_locale_from_accept_language_header || # Language header - browser config + I18n.default_locale # Set in your config files, english by super-default + + # Normalize and validate locale to prevent I18n::InvalidLocale errors + locale = normalize_locale(raw_locale) I18n.locale = locale session[:locale] = locale # Store the locale in the session @@ -280,5 +283,28 @@ def determine_layout def turbo_native_app? request.user_agent.to_s.include?('Turbo Native') end + + # Normalize locale parameter to prevent I18n::InvalidLocale errors + # @param raw_locale [String, Symbol, nil] The raw locale value to normalize + # @return [String] A valid, normalized locale string + def normalize_locale(raw_locale) + return I18n.default_locale.to_s if raw_locale.nil? + + # Convert to string and normalize case + candidate_locale = raw_locale.to_s.downcase.strip + + # Check if it's a valid available locale + available_locales = I18n.available_locales.map(&:to_s) + if available_locales.include?(candidate_locale) + candidate_locale + else + # Try to find a partial match (e.g., 'en-US' -> 'en') + partial_match = available_locales.find { |loc| candidate_locale.start_with?(loc) } + partial_match || I18n.default_locale.to_s + end + rescue StandardError => e + Rails.logger.warn("Error normalizing locale '#{raw_locale}': #{e.message}") + I18n.default_locale.to_s + end end end diff --git a/app/controllers/better_together/translations_controller.rb b/app/controllers/better_together/translations_controller.rb index 2907aa459..c80af820d 100644 --- a/app/controllers/better_together/translations_controller.rb +++ b/app/controllers/better_together/translations_controller.rb @@ -3,46 +3,427 @@ module BetterTogether class TranslationsController < ApplicationController # rubocop:todo Style/Documentation def index - # Get the locale filter from params, default to 'all' - @locale_filter = params[:locale_filter] || 'all' + # For overview tab - prepare statistics data @available_locales = I18n.available_locales.map(&:to_s) + @available_model_types = collect_all_model_types + @available_attributes = collect_available_attributes('all') - # Base query for translated model types - base_query = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation - .order(:translatable_type) + @data_type_summary = build_data_type_summary + @data_type_stats = calculate_data_type_stats - # Apply locale filter if specified - if @locale_filter != 'all' && @available_locales.include?(@locale_filter) - base_query = base_query.where(locale: @locale_filter) + # Calculate overview statistics + @locale_stats = calculate_locale_stats + @model_type_stats = calculate_model_type_stats + @attribute_stats = calculate_attribute_stats + @total_translation_records = calculate_total_records + + # Calculate model instance translation coverage + @model_instance_stats = calculate_model_instance_stats + end + + def by_locale + @page = params[:page] || 1 + + # Safely process locale parameter with comprehensive validation + begin + raw_locale = params[:locale] || I18n.available_locales.first.to_s + @locale_filter = raw_locale.to_s.downcase.strip + @available_locales = I18n.available_locales.map(&:to_s) + + # Ensure the locale_filter is valid + @locale_filter = I18n.available_locales.first.to_s unless @available_locales.include?(@locale_filter) + + # Validate with I18n to ensure it doesn't cause issues + I18n.with_locale(@locale_filter) { I18n.t('hello') } + rescue I18n::InvalidLocale => e + Rails.logger.warn("Invalid locale encountered: #{raw_locale} - #{e.message}") + @locale_filter = I18n.available_locales.first.to_s + end + + translation_records = fetch_translation_records_by_locale(@locale_filter) + @translations = Kaminari.paginate_array(translation_records).page(@page).per(100) + + respond_to do |format| + format.html { render partial: 'by_locale' } + end + end + + def by_model_type + @page = params[:page] || 1 + @model_type_filter = params[:model_type] || @available_model_types&.first&.dig(:name) + @available_model_types = collect_all_model_types + + translation_records = fetch_translation_records_by_model_type(@model_type_filter) + @translations = Kaminari.paginate_array(translation_records).page(@page).per(100) + + respond_to do |format| + format.html { render partial: 'by_model_type' } + end + end + + def by_data_type + @page = params[:page] || 1 + @data_type_filter = params[:data_type] || 'string' + @available_data_types = %w[string text rich_text file] + + translation_records = fetch_translation_records_by_data_type(@data_type_filter) + @translations = Kaminari.paginate_array(translation_records).page(@page).per(100) + + respond_to do |format| + format.html { render partial: 'by_data_type' } end + end + + def by_attribute + @page = params[:page] || 1 + @attribute_filter = params[:attribute] || 'name' + @available_attributes = collect_all_attributes - @translated_model_types = base_query.pluck(:translatable_type).uniq + translation_records = fetch_translation_records_by_attribute(@attribute_filter) + @translations = Kaminari.paginate_array(translation_records).page(@page).per(100) - # Calculate translation statistics per locale and model type - @translation_stats = calculate_translation_stats if @translated_model_types.any? + respond_to do |format| + format.html { render partial: 'by_attribute' } + end end private - def calculate_translation_stats + def collect_all_model_types + model_types = Set.new + + # Collect from all translation backends + collect_string_translation_models(model_types) + collect_text_translation_models(model_types) + collect_rich_text_translation_models(model_types) + collect_file_translation_models(model_types) + + # Convert to array and constantize + model_types.map do |type_name| + { name: type_name, class: type_name.constantize } + rescue StandardError => e + Rails.logger.warn "Could not constantize model type #{type_name}: #{e.message}" + nil + end.compact.sort_by { |type| type[:name] } + end + + def collect_available_attributes(model_filter = 'all') + return [] if model_filter == 'all' + + model_class = model_filter.constantize + attributes = [] + + # Add mobility attributes + if model_class.respond_to?(:mobility_attributes) + model_class.mobility_attributes.each do |attr| + attributes << { name: attr.to_s, type: 'text', source: 'mobility' } + end + end + + # Add translatable attachment attributes + if model_class.respond_to?(:mobility_translated_attachments) + model_class.mobility_translated_attachments&.keys&.each do |attr| + attributes << { name: attr.to_s, type: 'file', source: 'attachment' } + end + end + + attributes.sort_by { |attr| attr[:name] } + rescue StandardError => e + Rails.logger.error "Error collecting attributes for #{model_filter}: #{e.message}" + [] + end + + def fetch_translation_records + records = [] + + # Apply model type filter + model_types = if @model_type_filter == 'all' + @available_model_types.map { |mt| mt[:name] } + else + [@model_type_filter] + end + + model_types.each do |model_type| + # Fetch string/text translations + records.concat(fetch_key_value_translations(model_type, 'string')) + records.concat(fetch_key_value_translations(model_type, 'text')) + # Fetch rich text translations + records.concat(fetch_rich_text_translations(model_type)) + # Fetch file translations + records.concat(fetch_file_translations(model_type)) + end + + # Apply additional filters + records = apply_locale_filter(records) + records = apply_data_type_filter(records) + records = apply_attribute_filter(records) + + records.sort_by { |r| [r[:translatable_type], r[:translatable_id], r[:key]] } + end + + def fetch_key_value_translations(model_type, data_type) + return [] unless translation_class_exists?(data_type) + + translation_class = get_translation_class(data_type) + translations = translation_class.where(translatable_type: model_type) + + translations.map do |translation| + { + id: translation.id, + translatable_type: translation.translatable_type, + translatable_id: translation.translatable_id, + key: translation.key, + locale: translation.locale, + value: translation.value, + data_type: data_type, + source: 'mobility' + } + end + end + + def fetch_rich_text_translations(model_type) + return [] unless defined?(ActionText::RichText) + + rich_texts = ActionText::RichText.where(record_type: model_type) + + rich_texts.map do |rich_text| + { + id: rich_text.id, + translatable_type: rich_text.record_type, + translatable_id: rich_text.record_id, + key: rich_text.name, + locale: rich_text.locale, + value: rich_text.body.to_s.truncate(100), + data_type: 'rich_text', + source: 'action_text' + } + end + end + + def fetch_file_translations(model_type) + return [] unless defined?(ActiveStorage::Attachment) && + ActiveStorage::Attachment.column_names.include?('locale') + + attachments = ActiveStorage::Attachment.where(record_type: model_type) + + attachments.map do |attachment| + { + id: attachment.id, + translatable_type: attachment.record_type, + translatable_id: attachment.record_id, + key: attachment.name, + locale: attachment.locale, + value: attachment.filename.to_s, + data_type: 'file', + source: 'active_storage' + } + end + end + + def apply_locale_filter(records) + return records if @locale_filter == 'all' + + records.select { |record| record[:locale] == @locale_filter } + end + + def apply_data_type_filter(records) + return records if @data_type_filter == 'all' + + records.select { |record| record[:data_type] == @data_type_filter } + end + + def apply_attribute_filter(records) + return records if @attribute_filter == 'all' + + # Handle multiple attributes (comma-separated) + selected_attributes = @attribute_filter.split(',').map(&:strip) + records.select { |record| selected_attributes.include?(record[:key]) } + end + + def translation_class_exists?(data_type) + case data_type + when 'string' + defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + when 'text' + defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + else + false + end + end + + def get_translation_class(data_type) + case data_type + when 'string' + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + when 'text' + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + end + end + + def find_translated_models(data_type_filter = 'all') + model_types = Set.new + + # Collect models from each translation backend based on data type filter + case data_type_filter + when 'string' + collect_string_translation_models(model_types) + when 'text' + collect_text_translation_models(model_types) + when 'rich_text' + collect_rich_text_translation_models(model_types) + when 'file' + collect_file_translation_models(model_types) + else # 'all' + collect_string_translation_models(model_types) + collect_text_translation_models(model_types) + collect_rich_text_translation_models(model_types) + collect_file_translation_models(model_types) + end + + # Convert to array, constantize, and sort + model_types = model_types.map(&:constantize).sort_by(&:name) + + # Filter to only include models with mobility_attributes or translatable attachments + model_types.select do |model| + model.respond_to?(:mobility_attributes) || + model.respond_to?(:mobility_translated_attachments) + end + rescue StandardError => e + Rails.logger.error "Error finding translated models: #{e.message}" + [] + end + + def collect_string_translation_models(model_types) + return unless defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .distinct + .pluck(:translatable_type) + .each { |type| model_types.add(type) } + end + + def collect_text_translation_models(model_types) + return unless defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .distinct + .pluck(:translatable_type) + .each { |type| model_types.add(type) } + end + + def collect_rich_text_translation_models(model_types) + return unless defined?(ActionText::RichText) + + ActionText::RichText + .distinct + .pluck(:record_type) + .each { |type| model_types.add(type) } + end + + def collect_file_translation_models(model_types) + return unless defined?(ActiveStorage::Attachment) && + ActiveStorage::Attachment.column_names.include?('locale') + + ActiveStorage::Attachment + .distinct + .pluck(:record_type) + .each { |type| model_types.add(type) } + end + + def group_models_by_namespace(models) + grouped = models.group_by do |model| + # Extract namespace from class name (e.g., "BetterTogether::Community" -> "BetterTogether") + model.name.include?('::') ? model.name.split('::').first : 'Base' + end + + # Sort namespaces and models within each namespace + grouped.transform_values { |models_in_namespace| models_in_namespace.sort_by(&:name) } + .sort_by { |namespace, _| namespace } + .to_h + end + + def build_data_type_summary + { + string: { + description: 'Short text fields stored in mobility_string_translations table', + storage_table: 'mobility_string_translations', + backend: 'Mobility::Backends::ActiveRecord::KeyValue::StringTranslation' + }, + text: { + description: 'Long text fields stored in mobility_text_translations table', + storage_table: 'mobility_text_translations', + backend: 'Mobility::Backends::ActiveRecord::KeyValue::TextTranslation' + }, + rich_text: { + description: 'Rich text content with formatting stored via ActionText', + storage_table: 'action_text_rich_texts', + backend: 'ActionText::RichText' + }, + file: { + description: 'File attachments with locale support via ActiveStorage', + storage_table: 'active_storage_attachments (with locale column)', + backend: 'ActiveStorage::Attachment with locale' + } + } + end + + def calculate_data_type_stats + stats = {} + + # String translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + stats[:string] = { + total_records: Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.count, + unique_models: Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.distinct.count(:translatable_type) + } + end + + # Text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + stats[:text] = { + total_records: Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.count, + unique_models: Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.distinct.count(:translatable_type) + } + end + + # Rich text translations + if defined?(ActionText::RichText) + stats[:rich_text] = { + total_records: ActionText::RichText.count, + unique_models: ActionText::RichText.distinct.count(:record_type) + } + end + + # File translations + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + stats[:file] = { + total_records: ActiveStorage::Attachment.count, + unique_models: ActiveStorage::Attachment.distinct.count(:record_type) + } + end + + stats + end + + def calculate_translation_stats(models) + return {} if models.empty? + stats = {} - @translated_model_types.each do |model_type| - stats[model_type] = {} + models.each do |model| + stats[model.name] = {} @available_locales.each do |locale| # Count total records and translated records for this model and locale total_records = begin - model_type.constantize.count + model.count rescue StandardError 0 end - translated_count = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation - .where(translatable_type: model_type, locale: locale) - .distinct(:translatable_id) - .count - stats[model_type][locale] = { + translated_count = count_translated_records(model, locale) + + stats[model.name][locale] = { total: total_records, translated: translated_count, percentage: total_records > 0 ? ((translated_count.to_f / total_records) * 100).round(1) : 0 @@ -53,23 +434,662 @@ def calculate_translation_stats stats end + def count_translated_records(model, locale) + # Apply locale filter if specified + return 0 if @locale_filter != 'all' && @locale_filter != locale + + count = 0 + + # Count string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model.name, locale: locale) + .distinct(:translatable_id) + .count + end + + # Count text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model.name, locale: locale) + .distinct(:translatable_id) + .count + end + + # Count rich text translations (ActionText uses different structure) + if defined?(ActionText::RichText) + count += ActionText::RichText + .where(record_type: model.name, locale: locale) + .distinct(:record_id) + .count + end + + # Count file translations + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + count += ActiveStorage::Attachment + .where(record_type: model.name, locale: locale) + .distinct(:record_id) + .count + end + + count + end + def translate content = params[:content] source_locale = params[:source_locale] target_locale = params[:target_locale] initiator = helpers.current_person - # Initialize the TranslationBot - translation_bot = BetterTogether::TranslationBot.new + translation_job = BetterTogether::TranslationJob.perform_later( + content, source_locale, target_locale, initiator + ) + render json: { success: true, job_id: translation_job.job_id } + end - # Perform the translation using TranslationBot - translated_content = translation_bot.translate(content, target_locale:, - source_locale:, initiator:) + # Statistical calculation methods for overview + def calculate_locale_stats + stats = {} - # Return the translated content as JSON - render json: { translation: translated_content } - rescue StandardError => e - render json: { error: "Translation failed: #{e.message}" }, status: :unprocessable_content + I18n.available_locales.each do |locale| + count = 0 + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.where(locale: locale).count + end + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.where(locale: locale).count + end + + count += ActionText::RichText.where(locale: locale).count if defined?(ActionText::RichText) + + count += ActiveStorage::Attachment.where(locale: locale).count if defined?(ActiveStorage::Attachment) + + stats[locale] = count if count.positive? + end + + stats.sort_by { |_, count| -count }.to_h + end + + def calculate_model_type_stats + stats = {} + + # Collect from string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .group(:translatable_type) + .count + .each { |type, count| stats[type] = (stats[type] || 0) + count } + end + + # Collect from text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .group(:translatable_type) + .count + .each { |type, count| stats[type] = (stats[type] || 0) + count } + end + + # Collect from rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .group(:record_type) + .count + .each { |type, count| stats[type] = (stats[type] || 0) + count } + end + + # Collect from file translations + if defined?(ActiveStorage::Attachment) + ActiveStorage::Attachment + .group(:record_type) + .count + .each { |type, count| stats[type] = (stats[type] || 0) + count } + end + + stats.sort_by { |_, count| -count }.to_h + end + + def calculate_attribute_stats + stats = {} + + # Collect from string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .group(:key) + .count + .each { |key, count| stats[key] = (stats[key] || 0) + count } + end + + # Collect from text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .group(:key) + .count + .each { |key, count| stats[key] = (stats[key] || 0) + count } + end + + # Collect from rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .group(:name) + .count + .each { |name, count| stats[name] = (stats[name] || 0) + count } + end + + # NOTE: File translations don't have a key/name field in the same way + + stats.sort_by { |_, count| -count }.to_h + end + + def calculate_total_records + count = 0 + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.count + end + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + count += Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.count + end + + count += ActionText::RichText.count if defined?(ActionText::RichText) + + count += ActiveStorage::Attachment.count if defined?(ActiveStorage::Attachment) + + count + end + + # Calculate unique model instance translation coverage + def calculate_model_instance_stats + stats = {} + + @available_model_types.each do |model_type| + model_name = model_type[:name] + next unless model_name + + begin + model_class = model_name.constantize + + # Count only active instances (handle soft deletes if present) + total_instances = if model_class.respond_to?(:without_deleted) + model_class.without_deleted.count + elsif model_class.respond_to?(:with_deleted) + model_class.all.count # Paranoia gem - count without deleted + else + model_class.count + end + + # Get instances with any translations + translated_instances = calculate_translated_instance_count(model_name) + + # Get attribute-specific coverage + attribute_coverage = calculate_attribute_coverage_for_model(model_name, model_class) + + # Calculate coverage percentage with bounds checking + coverage_percentage = if total_instances.positive? && translated_instances <= total_instances + (translated_instances.to_f / total_instances * 100).round(1) + elsif translated_instances > total_instances + Rails.logger.warn "Translation coverage anomaly for #{model_name}: #{translated_instances} translated > #{total_instances} total" + 100.0 # Cap at 100% if there's a data inconsistency + else + 0.0 + end + + stats[model_name] = { + total_instances: total_instances, + translated_instances: translated_instances, + translation_coverage: coverage_percentage, + attribute_coverage: attribute_coverage + } + rescue StandardError => e + Rails.logger.warn "Error calculating model instance stats for #{model_name}: #{e.message}" + end + end + + stats.sort_by { |_, data| -data[:translated_instances] }.to_h + end + + def calculate_translated_instance_count(model_name) + instance_ids = Set.new + + # Collect translated instance IDs from string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_name) + .where.not(value: [nil, '']) # Only count non-empty translations + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:translatable_id) + .each { |id| instance_ids.add(id) } + end + + # Collect from text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name) + .where.not(value: [nil, '']) # Only count non-empty translations + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:translatable_id) + .each { |id| instance_ids.add(id) } + end + + # Collect from rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .where(record_type: model_name) + .where.not(body: [nil, '']) # Only count non-empty rich text + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:record_id) + .each { |id| instance_ids.add(id) } + end + + # Collect from file translations + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + ActiveStorage::Attachment + .where(record_type: model_name) + .where.not(locale: [nil, '']) # Only count attachments with explicit locales + .distinct + .pluck(:record_id) + .each { |id| instance_ids.add(id) } + end + + # Validate that these instance IDs actually exist as active records + return 0 if instance_ids.empty? + + begin + model_class = model_name.constantize + existing_ids = if model_class.respond_to?(:without_deleted) + model_class.without_deleted.where(id: instance_ids.to_a).pluck(:id) + else + model_class.where(id: instance_ids.to_a).pluck(:id) + end + existing_ids.count + rescue StandardError => e + Rails.logger.warn "Error validating translated instances for #{model_name}: #{e.message}" + instance_ids.count # Fallback to original count + end + end + + def calculate_attribute_coverage_for_model(model_name, model_class) + coverage = {} + + # Calculate total instances once (handle soft deletes) + total_instances = if model_class.respond_to?(:without_deleted) + model_class.without_deleted.count + elsif model_class.respond_to?(:with_deleted) + model_class.all.count + else + model_class.count + end + + # Get all mobility attributes for this model + if model_class.respond_to?(:mobility_attributes) + model_class.mobility_attributes.each do |attribute| + attribute_name = attribute.to_s + + # Count instances with translations for this specific attribute + instances_with_attribute = count_instances_with_attribute_translations(model_name, attribute_name) + + # Calculate coverage with bounds checking + coverage_percentage = if total_instances.positive? && instances_with_attribute <= total_instances + (instances_with_attribute.to_f / total_instances * 100).round(1) + elsif instances_with_attribute > total_instances + Rails.logger.warn "Attribute coverage anomaly for #{model_name}.#{attribute_name}: #{instances_with_attribute} > #{total_instances}" + 100.0 + else + 0.0 + end + + coverage[attribute_name] = { + instances_translated: instances_with_attribute, + total_instances: total_instances, + coverage_percentage: coverage_percentage, + attribute_type: 'mobility' + } + end + end + + # Get translatable attachment attributes + if model_class.respond_to?(:mobility_translated_attachments) + model_class.mobility_translated_attachments&.keys&.each do |attachment_name| + attachment_name = attachment_name.to_s + + # Count instances with file translations for this attachment + instances_with_attachment = count_instances_with_file_translations(model_name, attachment_name) + + # Calculate coverage with bounds checking + coverage_percentage = if total_instances.positive? && instances_with_attachment <= total_instances + (instances_with_attachment.to_f / total_instances * 100).round(1) + elsif instances_with_attachment > total_instances + Rails.logger.warn "File coverage anomaly for #{model_name}.#{attachment_name}: #{instances_with_attachment} > #{total_instances}" + 100.0 + else + 0.0 + end + + coverage[attachment_name] = { + instances_translated: instances_with_attachment, + total_instances: total_instances, + coverage_percentage: coverage_percentage, + attribute_type: 'file' + } + end + end + + coverage.sort_by { |_, data| -data[:instances_translated] }.to_h + end + + def count_instances_with_attribute_translations(model_name, attribute_name) + instance_ids = Set.new + + # Check string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_name, key: attribute_name) + .where.not(value: [nil, '']) # Only count non-empty translations + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:translatable_id) + .each { |id| instance_ids.add(id) } + end + + # Check text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name, key: attribute_name) + .where.not(value: [nil, '']) # Only count non-empty translations + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:translatable_id) + .each { |id| instance_ids.add(id) } + end + + # Check rich text translations (ActionText uses 'name' field) + if defined?(ActionText::RichText) + ActionText::RichText + .where(record_type: model_name, name: attribute_name) + .where.not(body: [nil, '']) # Only count non-empty rich text + .where.not(locale: [nil, '']) # Only count valid locales + .distinct + .pluck(:record_id) + .each { |id| instance_ids.add(id) } + end + + # Validate that these instance IDs actually exist as active records + return 0 if instance_ids.empty? + + begin + model_class = model_name.constantize + existing_ids = if model_class.respond_to?(:without_deleted) + model_class.without_deleted.where(id: instance_ids.to_a).pluck(:id) + else + model_class.where(id: instance_ids.to_a).pluck(:id) + end + existing_ids.count + rescue StandardError => e + Rails.logger.warn "Error validating attribute translated instances for #{model_name}: #{e.message}" + instance_ids.count # Fallback to original count + end + end + + def count_instances_with_file_translations(model_name, attachment_name) + return 0 unless defined?(ActiveStorage::Attachment) && + ActiveStorage::Attachment.column_names.include?('locale') + + ActiveStorage::Attachment + .where(record_type: model_name, name: attachment_name) + .where.not(locale: [nil, '']) + .distinct + .count(:record_id) + end + + # Fetch methods for new tab views + def fetch_translation_records_by_locale(locale) + records = [] + + # String translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .includes(:translatable) + .where(locale: locale) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'string') + end + end + + # Text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .includes(:translatable) + .where(locale: locale) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'text') + end + end + + # Rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .includes(:record) + .where(locale: locale) + .where.not(body: [nil, '']) + .find_each do |record| + records << format_rich_text_record(record) + end + end + + records.sort_by { |r| [r[:model_type], r[:translatable_id], r[:attribute]] } + end + + def fetch_translation_records_by_model_type(model_type) + return [] unless model_type + + records = [] + + # String translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .includes(:translatable) + .where(translatable_type: model_type) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'string') + end + end + + # Text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .includes(:translatable) + .where(translatable_type: model_type) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'text') + end + end + + # Rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .includes(:record) + .where(record_type: model_type) + .where.not(body: [nil, '']) + .find_each do |record| + records << format_rich_text_record(record) + end + end + + records.sort_by { |r| [r[:locale], r[:translatable_id], r[:attribute]] } + end + + def fetch_translation_records_by_data_type(data_type) + records = [] + + case data_type + when 'string' + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .includes(:translatable) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'string') + end + end + when 'text' + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .includes(:translatable) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'text') + end + end + when 'rich_text' + if defined?(ActionText::RichText) + ActionText::RichText + .includes(:record) + .where.not(body: [nil, '']) + .find_each do |record| + records << format_rich_text_record(record) + end + end + when 'file' + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + ActiveStorage::Attachment + .includes(:record) + .where.not(locale: [nil, '']) + .find_each do |record| + records << format_file_record(record) + end + end + end + + records.sort_by { |r| [r[:model_type], r[:locale], r[:translatable_id]] } + end + + def fetch_translation_records_by_attribute(attribute_name) + return [] unless attribute_name + + records = [] + + # String translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .includes(:translatable) + .where(key: attribute_name) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'string') + end + end + + # Text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .includes(:translatable) + .where(key: attribute_name) + .where.not(value: [nil, '']) + .find_each do |record| + records << format_translation_record(record, 'text') + end + end + + # Rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .includes(:record) + .where(name: attribute_name) + .where.not(body: [nil, '']) + .find_each do |record| + records << format_rich_text_record(record) + end + end + + records.sort_by { |r| [r[:model_type], r[:locale], r[:translatable_id]] } + end + + def collect_all_attributes + attributes = Set.new + + # Collect from string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .distinct + .pluck(:key) + .each { |attr| attributes.add(attr) } + end + + # Collect from text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .distinct + .pluck(:key) + .each { |attr| attributes.add(attr) } + end + + # Collect from rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .distinct + .pluck(:name) + .each { |attr| attributes.add(attr) } + end + + attributes.to_a.sort + end + + def format_translation_record(record, data_type) + { + id: record.id, + translatable_type: record.translatable_type, + translatable_id: record.translatable_id, + attribute: record.key, + locale: record.locale, + data_type: data_type, + value: truncate_value(record.value), + full_value: record.value, + model_type: record.translatable_type&.split('::')&.last || record.translatable_type + } + end + + def format_rich_text_record(record) + { + id: record.id, + translatable_type: record.record_type, + translatable_id: record.record_id, + attribute: record.name, + locale: record.locale, + data_type: 'rich_text', + value: truncate_value(record.body.to_plain_text), + full_value: record.body.to_s, + model_type: record.record_type&.split('::')&.last || record.record_type + } + end + + def format_file_record(record) + { + id: record.id, + translatable_type: record.record_type, + translatable_id: record.record_id, + attribute: record.name, + locale: record.locale, + data_type: 'file', + value: record.filename.to_s, + full_value: record.filename.to_s, + model_type: record.record_type&.split('::')&.last || record.record_type + } + end + + def truncate_value(value, limit = 100) + return '' if value.nil? + + text = value.to_s.strip + text.length > limit ? "#{text[0..limit]}..." : text end end end diff --git a/app/helpers/better_together/application_helper.rb b/app/helpers/better_together/application_helper.rb index 44b4b03bd..f55cf0ad0 100644 --- a/app/helpers/better_together/application_helper.rb +++ b/app/helpers/better_together/application_helper.rb @@ -224,5 +224,30 @@ def event_relationship_icon(person, event) # rubocop:todo Metrics/MethodLength tooltip: t('better_together.events.relationship.calendar', default: 'Calendar event') } end end + + # Helper for translation data type badge colors + def data_type_color(data_type) + case data_type.to_s + when 'string' + 'primary' + when 'text' + 'success' + when 'rich_text' + 'warning' + when 'file' + 'info' + else + 'secondary' + end + end + + # Formats locale code for display (uppercase) + # @param locale [String, Symbol] The locale code to format + # @return [String] The formatted locale display string + def format_locale_display(locale) + return '' if locale.nil? + + locale.to_s.upcase + end end end diff --git a/app/javascript/controllers/better_together/translation_manager_controller.js b/app/javascript/controllers/better_together/translation_manager_controller.js new file mode 100644 index 000000000..964f0c39e --- /dev/null +++ b/app/javascript/controllers/better_together/translation_manager_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = [] + + connect() { + console.log('Translation Manager controller connected'); + + // Handle tab activation for lazy loading + this.bindTabEvents(); + } + + bindTabEvents() { + const tabButtons = document.querySelectorAll('#translationTabs button[data-bs-toggle="tab"]'); + + tabButtons.forEach(tab => { + tab.addEventListener('shown.bs.tab', (event) => { + const tabId = event.target.getAttribute('aria-controls'); + console.log(`Tab activated: ${tabId}`); + + // Each tab has its own turbo frame that will automatically load via lazy loading + // The src attribute on each turbo frame handles the loading + }); + }); + } + + // Tab switching is now handled by Bootstrap and Turbo Frames + // Each tab loads its content independently via lazy loading +} \ No newline at end of file diff --git a/app/views/better_together/translations/_by_attribute.html.erb b/app/views/better_together/translations/_by_attribute.html.erb new file mode 100644 index 000000000..e7baf61f7 --- /dev/null +++ b/app/views/better_together/translations/_by_attribute.html.erb @@ -0,0 +1,115 @@ + +<%= turbo_frame_tag :translations_by_attribute do %> +
+
+
+

+ + <%= t('.by_attribute_title') %> +

+ + + +
+ + + <% if @attribute_filter %> +
+ + <%= t('.showing_records_for_attribute', attribute: @attribute_filter, count: @translations.total_count) %> +
+ <% end %> + + + <% if @translations&.any? %> +
+
+
+ + + + + + + + + + + + + <% @translations.each do |translation| %> + + + + + + + + + <% end %> + +
<%= t('.model_type') %><%= t('.record_id') %><%= t('.locale') %><%= t('.data_type') %><%= t('.value') %><%= t('.actions') %>
+ + <%= translation[:model_type] %> + + + <%= translation[:translatable_id] %> + + + <%= format_locale_display(translation[:locale]) %> + + + + <%= t("translations.index.data_type_names.#{translation[:data_type]}") %> + + +
+ <%= translation[:value] %> +
+
+
+ +
+
+
+
+
+ + +
+ <%= paginate @translations, + params: { attribute: @attribute_filter }, + remote: true, + data: { turbo_frame: :translations_by_attribute } %> +
+ <% else %> +
+ + <% if @attribute_filter %> + <%= t('.no_translations_for_attribute', attribute: @attribute_filter) %> + <% else %> + <%= t('.select_attribute_first') %> + <% end %> +
+ <% end %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/_by_data_type.html.erb b/app/views/better_together/translations/_by_data_type.html.erb new file mode 100644 index 000000000..ad9e98bdc --- /dev/null +++ b/app/views/better_together/translations/_by_data_type.html.erb @@ -0,0 +1,112 @@ + +<%= turbo_frame_tag :translations_by_data_type do %> +
+
+
+

+ + <%= t('.by_data_type_title') %> +

+ + + +
+ + +
+ + <%= t('.showing_records_for_data_type', + data_type: t("translations.index.data_type_names.#{@data_type_filter}"), + count: @translations.total_count) %> +
+ + + <% if @translations.any? %> +
+
+
+ + + + + + + + + + + + + <% @translations.each do |translation| %> + + + + + + + + + <% end %> + +
<%= t('.model_type') %><%= t('.record_id') %><%= t('.attribute') %><%= t('.locale') %><%= t('.value') %><%= t('.actions') %>
+ + <%= translation[:model_type] %> + + + <%= translation[:translatable_id] %> + + + <%= translation[:attribute] %> + + + + <%= format_locale_display(translation[:locale]) %> + + +
+ <%= translation[:value] %> +
+
+
+ +
+
+
+
+
+ + +
+ <%= paginate @translations, + params: { data_type: @data_type_filter }, + remote: true, + data: { turbo_frame: :translations_by_data_type } %> +
+ <% else %> +
+ + <%= t('.no_translations_for_data_type', + data_type: t("translations.index.data_type_names.#{@data_type_filter}")) %> +
+ <% end %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/_by_locale.html.erb b/app/views/better_together/translations/_by_locale.html.erb new file mode 100644 index 000000000..27e58c530 --- /dev/null +++ b/app/views/better_together/translations/_by_locale.html.erb @@ -0,0 +1,111 @@ + +<%= turbo_frame_tag :translations_by_locale do %> +
+
+
+

+ + <%= t('.by_locale_title') %> +

+ + + +
+ + +
+ +

+ <%= t('.showing_records_for_locale', locale: @locale_filter, count: @translations.total_count) %> +

+
+ + + <% if @translations.any? %> +
+
+
+ + + + + + + + + + + + + <% @translations.each do |translation| %> + + + + + + + + + <% end %> + +
<%= t('.model_type') %><%= t('.record_id') %><%= t('.attribute') %><%= t('.data_type') %><%= t('.value') %><%= t('.actions') %>
+ + <%= translation[:model_type] %> + + + <%= translation[:translatable_id] %> + + + <%= translation[:attribute] %> + + + + <%= t("translations.index.data_type_names.#{translation[:data_type]}") %> + + +
+ <%= translation[:value] %> +
+
+
+ +
+
+
+
+
+ + +
+ <%= paginate @translations, + params: { locale: @locale_filter }, + remote: true, + data: { turbo_frame: :translations_by_locale } %> +
+ <% else %> +
+ + <%= t('.no_translations_for_locale', locale: format_locale_display(@locale_filter)) %> +
+ <% end %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/_by_model_type.html.erb b/app/views/better_together/translations/_by_model_type.html.erb new file mode 100644 index 000000000..34ef02bc6 --- /dev/null +++ b/app/views/better_together/translations/_by_model_type.html.erb @@ -0,0 +1,115 @@ + +<%= turbo_frame_tag :translations_by_model_type do %> +
+
+
+

+ + <%= t('.by_model_type_title') %> +

+ + + +
+ + + <% if @model_type_filter %> +
+ + <%= t('.showing_records_for_model', model: @model_type_filter.split('::').last, count: @translations.total_count) %> +
+ <% end %> + + + <% if @translations&.any? %> +
+
+
+ + + + + + + + + + + + + <% @translations.each do |translation| %> + + + + + + + + + <% end %> + +
<%= t('.record_id') %><%= t('.attribute') %><%= t('.locale') %><%= t('.data_type') %><%= t('.value') %><%= t('.actions') %>
+ <%= translation[:translatable_id] %> + + + <%= translation[:attribute] %> + + + + <%= format_locale_display(translation[:locale]) %> + + + + <%= t("translations.index.data_type_names.#{translation[:data_type]}") %> + + +
+ <%= translation[:full_value] %> +
+
+
+ +
+
+
+
+
+ + +
+ <%= paginate @translations, + params: { model_type: @model_type_filter }, + remote: true, + data: { turbo_frame: :translations_by_model_type } %> +
+ <% else %> +
+ + <% if @model_type_filter %> + <%= t('.no_translations_for_model', model: @model_type_filter.split('::').last) %> + <% else %> + <%= t('.select_model_type_first') %> + <% end %> +
+ <% end %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/_overview.html.erb b/app/views/better_together/translations/_overview.html.erb new file mode 100644 index 000000000..8840a31ed --- /dev/null +++ b/app/views/better_together/translations/_overview.html.erb @@ -0,0 +1,279 @@ + +
+
+

+ + <%= t('.data_type_overview') %> +

+
+ <% @data_type_summary.each do |type, info| %> +
+
+
+
+ + <%= type.to_s.humanize %> +
+

<%= info[:description] %>

+
+ + Storage: <%= info[:storage_table] %>
+ Backend: <%= info[:backend] %> +
+ <% if @data_type_stats[type] %> +
+ + <%= @data_type_stats[type][:total_records] %> records + + + <%= @data_type_stats[type][:unique_models] %> models + +
+ <% end %> +
+
+
+
+ <% end %> +
+
+
+ + +
+
+

+ + <%= t('.translation_statistics') %> +

+
+
+ + +
+
+
+
+
+ + <%= t('.locale_breakdown') %> +
+
+
+ <% if @locale_stats&.any? %> + <% @locale_stats.each do |locale, count| %> +
+ + <%= format_locale_display(locale) %> + + + <%= number_with_delimiter(count) %> <%= t('.records') %> + +
+ <% end %> + <% else %> +

<%= t('.no_locale_data') %>

+ <% end %> +
+
+
+ + +
+
+
+
+ + <%= t('.model_type_breakdown') %> +
+
+
+ <% if @model_type_stats&.any? %> + <% @model_type_stats.each do |model_type, count| %> +
+ + <%= model_type.split('::').last %> + + + <%= number_with_delimiter(count) %> <%= t('.records') %> + +
+ <% end %> + <% else %> +

<%= t('.no_model_type_data') %>

+ <% end %> +
+
+
+ + +
+
+
+
+ + <%= t('.attribute_breakdown') %> +
+
+
+ <% if @attribute_stats&.any? %> + <% @attribute_stats.first(10).each do |attribute, count| %> +
+ + <%= attribute %> + + + <%= number_with_delimiter(count) %> <%= t('.records') %> + +
+ <% end %> + <% if @attribute_stats.size > 10 %> +
+ + <%= t('.and_more_attributes', count: @attribute_stats.size - 10) %> + +
+ <% end %> + <% else %> +

<%= t('.no_attribute_data') %>

+ <% end %> +
+
+
+
+ + +
+
+

+ + <%= t('.model_instance_coverage') %> +

+ <% if @model_instance_stats&.any? %> +
+ <% @model_instance_stats.each do |model_name, stats| %> +
+
+
+
+ <%= model_name.split('::').last %> +
+ + <%= stats[:coverage_percentage] || 0 %>% coverage + +
+
+ +
+
+
+
<%= stats[:translated_instances] || 0 %>
+ Translated +
+
+
+
<%= stats[:total_instances] || 0 %>
+ Total +
+
+ + +
+
+
+
+ + + <% if stats[:attribute_coverage]&.any? %> +
<%= t('.attribute_coverage_title') %>
+
+ <% stats[:attribute_coverage].first(5).each do |attr, attr_stats| %> +
+ + <%= attr %> + + + <%= attr_stats[:instances_translated] || 0 %>/<%= attr_stats[:total_instances] || 0 %> + (<%= attr_stats[:coverage_percentage] || 0 %>%) + +
+ <% end %> + <% if stats[:attribute_coverage].size > 5 %> +
+ + <%= t('.and_more_attributes', count: stats[:attribute_coverage].size - 5) %> + +
+ <% end %> +
+ <% else %> +

<%= t('.no_attribute_coverage') %>

+ <% end %> +
+
+
+ <% end %> +
+ <% else %> +
+ + <%= t('.no_model_instance_data') %> +
+ <% end %> +
+
+ + +
+
+
+
+
+ + <%= t('.summary_statistics') %> +
+
+
+
+
+
+

+ <%= number_with_delimiter(@total_translation_records || 0) %> +

+ <%= t('.total_translation_records') %> +
+
+
+
+

+ <%= @available_locales&.size || 0 %> +

+ <%= t('.supported_locales') %> +
+
+
+
+

+ <%= @available_model_types&.size || 0 %> +

+ <%= t('.translatable_models') %> +
+
+
+
+

+ <%= @available_attributes&.size || 0 %> +

+ <%= t('.unique_attributes') %> +
+
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/better_together/translations/_records.html.erb b/app/views/better_together/translations/_records.html.erb new file mode 100644 index 000000000..2f62a044d --- /dev/null +++ b/app/views/better_together/translations/_records.html.erb @@ -0,0 +1,242 @@ +<%= turbo_frame_tag :translation_records do %> +
+ +
+
+

+ + <%= t('.filter_controls') %> +

+
+
+ <%= form_with url: better_together.records_translations_path, method: :get, id: 'translation-filters', local: true, data: { 'better-together--translation-manager-target': 'filterForm', turbo_frame: :translation_records } do |form| %> +
+ +
+ <%= form.label :locale_filter, t('.locale_filter'), class: 'form-label' %> + <%= form.select :locale_filter, + options_for_select([['All Locales', 'all']] + @available_locales.map { |locale| [t("locales.#{locale}"), locale] }, @locale_filter), + {}, + { + class: 'form-select', + data: { + controller: 'better_together--slim-select', + 'better-together--translation-manager-target': 'localeFilter', + action: 'change->better-together--translation-manager#updateFilters' + } + } %> +
+ + +
+ <%= form.label :data_type_filter, t('.data_type_filter'), class: 'form-label' %> + <%= form.select :data_type_filter, + options_for_select([ + ['All Types', 'all'], + ['String', 'string'], + ['Text', 'text'], + ['Rich Text', 'rich_text'], + ['File', 'file'] + ], @data_type_filter), + {}, + { + class: 'form-select', + data: { + controller: 'better_together--slim-select', + 'better-together--translation-manager-target': 'dataTypeFilter', + action: 'change->better-together--translation-manager#updateFilters' + } + } %> +
+ + +
+ <%= form.label :model_type_filter, t('.model_type_filter'), class: 'form-label' %> + <%= form.select :model_type_filter, + options_for_select([['All Models', 'all']] + @available_model_types.map { |mt| [mt[:name], mt[:name]] }, @model_type_filter), + {}, + { + class: 'form-select', + data: { + controller: 'better_together--slim-select', + 'better-together--translation-manager-target': 'modelTypeFilter', + action: 'change->better-together--translation-manager#updateModelType' + } + } %> +
+ + +
+ <%= form.label :attribute_filter, t('.attribute_filter'), class: 'form-label' %> + <%= form.select :attribute_filter, + options_for_select([['All Attributes', 'all']] + @available_attributes.map { |attr| [attr[:name], attr[:name]] }, @attribute_filter), + {}, + { + class: 'form-select', + multiple: true, + data: { + controller: 'better_together--slim-select', + 'better-together--translation-manager-target': 'attributeFilter', + action: 'change->better-together--translation-manager#updateFilters', + 'better_together--slim_select-options-value': { + settings: { + multiple: true, + closeOnSelect: false, + placeholder: 'Select attributes...' + } + } + } + } %> +
+
+ +
+
+ <%= form.submit t('.apply_filters'), class: 'btn btn-primary me-2' %> + <%= link_to t('.clear_filters'), better_together.records_translations_path, class: 'btn btn-outline-secondary', data: { turbo_frame: :translation_records } %> +
+
+ <% end %> +
+
+ + + <% if @locale_filter != 'all' || @data_type_filter != 'all' || @model_type_filter != 'all' || @attribute_filter != 'all' %> + + <% end %> + + +
+
+

+ + <%= t('.translation_records') %> +

+ + <%= @translation_records.count %> <%= t('.records_found') %> + +
+
+ <% if @translation_records.any? %> +
+ + + + + + + + + + + + + + + <% @translation_records.each do |record| %> + + + + + + + + + + + <% end %> + +
+ + <%= t('.record_id') %> + + + <%= t('.translatable_type') %> + + + <%= t('.translatable_id') %> + + + <%= t('.attribute_key') %> + + + <%= t('.locale') %> + + + <%= t('.data_type') %> + + + <%= t('.value') %> + + + <%= t('.actions') %> +
+ <%= record[:id] %> + + + <%= record[:translatable_type].split('::').last %> + + + <%= record[:translatable_id] %> + + <%= record[:key] %> + + + <%= record[:locale] %> + + + + <%= record[:data_type].humanize %> + + + <% if record[:data_type] == 'file' %> + + <%= record[:value] %> + <% elsif record[:data_type] == 'rich_text' %> + + + <%= truncate(strip_tags(record[:value]), length: 50) %> + + <% else %> + <%= truncate(record[:value], length: 100) %> + <% end %> + +
+ + +
+
+
+ <% else %> +
+ +

<%= t('.no_records_found') %>

+

<%= t('.try_adjusting_filters') %>

+
+ <% end %> +
+
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/index.html.erb b/app/views/better_together/translations/index.html.erb index 09e073644..c7e715644 100644 --- a/app/views/better_together/translations/index.html.erb +++ b/app/views/better_together/translations/index.html.erb @@ -1,281 +1,102 @@ <% content_for :page_title, t('.title') %> -
+

<%= t('.title') %>

- - <% if @translated_model_types.any? %> - <% - # Get current locale filter from params - selected_locale = params[:locale_filter] || 'all' - available_locales = I18n.available_locales.map(&:to_s) - - # Group model types by namespace - grouped_models = @translated_model_types.group_by do |model_type| - parts = model_type.split('::') - parts.length > 1 ? parts[0..-2].join('::') : 'Base' - end - - # Sort groups with 'BetterTogether' first, then 'Base', then alphabetically - sorted_groups = grouped_models.sort_by do |namespace, _| - case namespace - when 'BetterTogether' then '0' - when 'Base' then 'z' - else namespace - end - end - %> - - -
-

- - Filter by Locale -

- + + + + + +
+ +
+ <%= render 'overview' %> +
+ + +
+ <%= turbo_frame_tag :translations_by_locale, loading: :lazy, src: better_together.by_locale_translations_path do %> +
+
+ <%= t('.loading') %> +
+

<%= t('.loading_by_locale') %>

+
+ <% end %> +
+ + +
+ <%= turbo_frame_tag :translations_by_model_type, loading: :lazy, src: better_together.by_model_type_translations_path do %> +
+
+ <%= t('.loading') %> +
+

<%= t('.loading_by_model_type') %>

+
+ <% end %> +
+ + +
+ <%= turbo_frame_tag :translations_by_data_type, loading: :lazy, src: better_together.by_data_type_translations_path do %> +
+
+ <%= t('.loading') %> +
+

<%= t('.loading_by_data_type') %>

+
+ <% end %>
- - <% if selected_locale != 'all' %> - - <% end %> - - <% sorted_groups.each_with_index do |(namespace, models), group_index| %> -
-

- - <%= namespace == 'Base' ? 'Core Models' : namespace.humanize %> - <%= models.count %> -

- - - - -
- <% models.each_with_index do |model_type, model_index| %> - <% - model_class = model_type.constantize - panel_id = "#{model_type.downcase.gsub('::', '-')}-panel" - tab_id = "#{model_type.downcase.gsub('::', '-')}-tab" - is_active = group_index == 0 && model_index == 0 - - class_name = model_type.split('::').last - %> -
-
-
-

- - <%= model_class.model_name.human %> - (<%= model_type %>) -

- <% if selected_locale != 'all' %> - - - <%= t("locales.#{selected_locale}") %> Only - - <% end %> -
-
- <% - translated_attributes = model_class.respond_to?(:mobility_attributes) ? model_class.mobility_attributes : [] - %> - - <% if translated_attributes.any? %> -
-
- - Translation Values for <%= model_class.model_name.human %> -
- -
- - - - - - <% translated_attributes.each do |attribute| %> - - <% end %> - - - - - - - - -
- - ID - - - Identifier - - - <%= attribute.to_s.humanize %> - - - Actions -
- - No <%= model_class.model_name.human.downcase %> records to display. - Translation data will appear here when available. -
-
-
- -
-
-
Translated Attributes:
-
    - <% translated_attributes.each do |attribute| %> -
  • - - - <%= attribute %> - - - <%= attribute.to_s.humanize.downcase %> - -
  • - <% end %> -
-
- -
-
Model Information:
-
    -
  • Full Class: <%= model_type %>
  • -
  • Namespace: <%= namespace %>
  • -
  • Model Name: <%= class_name %>
  • -
  • Attributes Count: <%= translated_attributes.count %>
  • -
- -
- - - These attributes support multiple language translations through the Mobility gem. - -
-
-
- <% else %> - - -
-
Model Information:
-
    -
  • Full Class: <%= model_type %>
  • -
  • Namespace: <%= namespace %>
  • -
  • Model Name: <%= class_name %>
  • -
-
- <% end %> -
-
-
- <% end %> + +
+ <%= turbo_frame_tag :translations_by_attribute, loading: :lazy, src: better_together.by_attribute_translations_path do %> +
+
+ <%= t('.loading') %> +
+

<%= t('.loading_by_attribute') %>

-
- <% end %> - <% else %> - - <% end %> +
\ No newline at end of file diff --git a/app/views/layouts/better_together/_locale_switcher.html.erb b/app/views/layouts/better_together/_locale_switcher.html.erb index 505762279..9516ff098 100644 --- a/app/views/layouts/better_together/_locale_switcher.html.erb +++ b/app/views/layouts/better_together/_locale_switcher.html.erb @@ -1,7 +1,7 @@
-
+

<%= number_with_delimiter(@total_translation_records || 0) %> @@ -19,7 +19,15 @@ <%= t('.total_translation_records') %>

-
+
+
+

+ <%= number_with_delimiter(@unique_translated_records || 0) %> +

+ <%= t('.unique_translated_records') %> +
+
+

<%= @available_locales&.size || 0 %> @@ -27,7 +35,7 @@ <%= t('.supported_locales') %>

-
+

<%= @available_model_types&.size || 0 %> @@ -35,7 +43,7 @@ <%= t('.translatable_models') %>

-
+

<%= @available_attributes&.size || 0 %> @@ -173,7 +181,7 @@

<% if @attribute_stats&.any? %> - <% @attribute_stats.first(10).each do |attribute, count| %> + <% @attribute_stats.each do |attribute, count| %>
<%= attribute %> @@ -183,13 +191,6 @@
<% end %> - <% if @attribute_stats.size > 10 %> -
- - <%= t('.and_more_attributes', count: @attribute_stats.size - 10) %> - -
- <% end %> <% else %>

<%= t('.no_attribute_data') %>

<% end %> @@ -248,7 +249,7 @@ <% if stats[:attribute_coverage]&.any? %>
<%= t('.attribute_coverage_title') %>
- <% stats[:attribute_coverage].first(5).each do |attr, attr_stats| %> + <% stats[:attribute_coverage].each do |attr, attr_stats| %>
<%= attr %> @@ -259,13 +260,6 @@
<% end %> - <% if stats[:attribute_coverage].size > 5 %> -
- - <%= t('.and_more_attributes', count: stats[:attribute_coverage].size - 5) %> - -
- <% end %>
<% elsif stats[:total_instances] == 0 %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index c1e0e10ad..545a161e3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1515,6 +1515,7 @@ en: no_attribute_data: No attribute data available and_more_attributes: "and %{count} more attributes..." total_translation_records: Total Translation Records + unique_translated_records: Unique Translated Records supported_locales: Supported Locales translatable_models: Translatable Models unique_attributes: Unique Attributes @@ -1568,6 +1569,7 @@ en: no_attribute_data: No attribute data available and_more_attributes: "and %{count} more attributes..." total_translation_records: Total Translation Records + unique_translated_records: Unique Translated Records supported_locales: Supported Locales translatable_models: Translatable Models unique_attributes: Unique Attributes diff --git a/config/locales/es.yml b/config/locales/es.yml index d8150620b..6b2d851fa 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1526,6 +1526,7 @@ es: no_attribute_data: No hay datos de atributo disponibles and_more_attributes: "y %{count} atributos más..." total_translation_records: Total de Registros de Traducción + unique_translated_records: Registros Traducidos Únicos supported_locales: Idiomas Soportados translatable_models: Modelos Traducibles unique_attributes: Atributos Únicos @@ -1579,6 +1580,7 @@ es: no_attribute_data: No hay datos de atributo disponibles and_more_attributes: "y %{count} atributos más..." total_translation_records: Total de Registros de Traducción + unique_translated_records: Registros Traducidos Únicos supported_locales: Idiomas Soportados translatable_models: Modelos Traducibles unique_attributes: Atributos Únicos diff --git a/config/locales/fr.yml b/config/locales/fr.yml index b07de8c42..0f7a43e9a 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1534,6 +1534,7 @@ fr: no_attribute_data: Aucune donnée d'attribut disponible and_more_attributes: "et %{count} attributs de plus..." total_translation_records: Total des Enregistrements de Traduction + unique_translated_records: Enregistrements Traduits Uniques supported_locales: Langues Supportées translatable_models: Modèles Traduisibles unique_attributes: Attributs Uniques @@ -1587,6 +1588,7 @@ fr: no_attribute_data: Aucune donnée d'attribut disponible and_more_attributes: "et %{count} attributs de plus..." total_translation_records: Total des Enregistrements de Traduction + unique_translated_records: Enregistrements Traduits Uniques supported_locales: Langues Supportées translatable_models: Modèles Traduisibles unique_attributes: Attributs Uniques From a2ba1cb5b95c6acb5778a6ce50f7abea75040c7f Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Mon, 20 Oct 2025 21:55:05 -0230 Subject: [PATCH 5/8] Add locale-specific and overall translation coverage views - Updated the translations overview to include a toggle for standard and locale-focused views. - Introduced new partials for locale-specific coverage and overall coverage analysis. - Enhanced the UI with progress bars and detailed statistics for translation coverage by locale. - Modified existing translation messages to accommodate new features and improved clarity. - Added JavaScript for dynamic view toggling and locale detail display. --- .../translations_controller.rb | 283 ++++++++++++++ .../better_together/translations_helper.rb | 350 ++++++++++++++++++ .../translations/_by_locale.html.erb | 2 +- .../_locale_specific_coverage.html.erb | 176 +++++++++ .../translations/_overall_coverage.html.erb | 187 ++++++++++ .../translations/_overview.html.erb | 281 +++++++++++--- config/locales/en.yml | 28 ++ config/locales/es.yml | 28 ++ config/locales/fr.yml | 28 ++ 9 files changed, 1301 insertions(+), 62 deletions(-) create mode 100644 app/helpers/better_together/translations_helper.rb create mode 100644 app/views/better_together/translations/_locale_specific_coverage.html.erb create mode 100644 app/views/better_together/translations/_overall_coverage.html.erb diff --git a/app/controllers/better_together/translations_controller.rb b/app/controllers/better_together/translations_controller.rb index 8920759dd..583fc8bd4 100644 --- a/app/controllers/better_together/translations_controller.rb +++ b/app/controllers/better_together/translations_controller.rb @@ -20,6 +20,9 @@ def index # Calculate model instance translation coverage @model_instance_stats = calculate_model_instance_stats + + # Calculate locale gap summary for enhanced view + @locale_gap_summary = calculate_locale_gap_summary end def by_locale @@ -1353,5 +1356,285 @@ def has_translatable_attachment?(model_class, attachment_name) false end + + # Calculate per-locale translation coverage for a specific model + def calculate_locale_coverage_for_model(_model_name, model_class) + locale_coverage = {} + + # Get all translatable attributes for this model (including STI descendants) + all_attributes = collect_model_translatable_attributes(model_class) + return locale_coverage if all_attributes.empty? + + # Calculate coverage for each available locale + I18n.available_locales.each do |locale| + locale_str = locale.to_s + locale_coverage[locale_str] = { + total_attributes: all_attributes.length, + translated_attributes: 0, + missing_attributes: [], + completion_percentage: 0.0 + } + + all_attributes.each do |attribute_name, backend_type| + has_translation = case backend_type + when :string, :text + has_string_text_translation?(model_class, attribute_name, locale_str) + when :action_text + has_action_text_translation?(model_class, attribute_name, locale_str) + when :active_storage + has_active_storage_translation?(model_class, attribute_name, locale_str) + else + false + end + + if has_translation + locale_coverage[locale_str][:translated_attributes] += 1 + else + locale_coverage[locale_str][:missing_attributes] << attribute_name + end + end + + # Calculate completion percentage + next unless locale_coverage[locale_str][:total_attributes] > 0 + + locale_coverage[locale_str][:completion_percentage] = + (locale_coverage[locale_str][:translated_attributes].to_f / + locale_coverage[locale_str][:total_attributes] * 100).round(1) + end + + locale_coverage + end + + # Check if model has translation for specific string/text attribute in given locale + def has_string_text_translation?(model_class, attribute_name, locale) + # Use KeyValue backend - check mobility_string_translations and mobility_text_translations + string_table = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.table_name + text_table = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.table_name + + [string_table, text_table].each do |table_name| + next unless ActiveRecord::Base.connection.table_exists?(table_name) + + # Build Arel query to check for translations safely + table = Arel::Table.new(table_name) + query = table.project(1) + .where(table[:translatable_type].eq(model_class.name)) + .where(table[:key].eq(attribute_name)) + .where(table[:locale].eq(locale)) + .where(table[:value].not_eq(nil)) + .where(table[:value].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(query.to_sql) + return true if result.rows.any? + + # Check STI descendants if applicable + next unless model_class.respond_to?(:descendants) && model_class.descendants.any? + + model_class.descendants.each do |subclass| + next unless subclass.respond_to?(:mobility_attributes) + + descendant_query = table.project(1) + .where(table[:translatable_type].eq(subclass.name)) + .where(table[:key].eq(attribute_name)) + .where(table[:locale].eq(locale)) + .where(table[:value].not_eq(nil)) + .where(table[:value].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(descendant_query.to_sql) + return true if result.rows.any? + end + end + + false + rescue StandardError => e + Rails.logger.warn("Error checking string/text translation for #{model_class.name}.#{attribute_name} in #{locale}: #{e.message}") + false + end + + # Check if model has translation for specific Action Text attribute in given locale + def has_action_text_translation?(model_class, attribute_name, locale) + return false unless ActiveRecord::Base.connection.table_exists?('action_text_rich_texts') + + # Build Arel query for Action Text translations + table = Arel::Table.new('action_text_rich_texts') + query = table.project(1) + .where(table[:record_type].eq(model_class.name)) + .where(table[:name].eq(attribute_name)) + .where(table[:locale].eq(locale)) + .where(table[:body].not_eq(nil)) + .where(table[:body].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(query.to_sql) + return true if result.rows.any? + + # Check STI descendants + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + descendant_query = table.project(1) + .where(table[:record_type].eq(subclass.name)) + .where(table[:name].eq(attribute_name)) + .where(table[:locale].eq(locale)) + .where(table[:body].not_eq(nil)) + .where(table[:body].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(descendant_query.to_sql) + return true if result.rows.any? + end + end + + false + rescue StandardError => e + Rails.logger.warn("Error checking Action Text translation for #{model_class.name}.#{attribute_name} in #{locale}: #{e.message}") + false + end + + # Check if model has translation for specific Active Storage attachment in given locale + def has_active_storage_translation?(model_class, attachment_name, locale) + # For Active Storage, we need to check if there are attachments with the given locale + # Active Storage translations are typically handled through the KeyValue backend as well + # Let's check both mobility_string_translations and mobility_text_translations for active_storage keys + + string_table = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.table_name + text_table = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.table_name + + [string_table, text_table].each do |table_name| + next unless ActiveRecord::Base.connection.table_exists?(table_name) + + # Build Arel query to check for Active Storage translations in KeyValue backend + table = Arel::Table.new(table_name) + query = table.project(1) + .where(table[:translatable_type].eq(model_class.name)) + .where(table[:key].eq(attachment_name)) + .where(table[:locale].eq(locale)) + .where(table[:value].not_eq(nil)) + .where(table[:value].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(query.to_sql) + return true if result.rows.any? + + # Check STI descendants if applicable + next unless model_class.respond_to?(:descendants) && model_class.descendants.any? + + model_class.descendants.each do |subclass| + descendant_query = table.project(1) + .where(table[:translatable_type].eq(subclass.name)) + .where(table[:key].eq(attachment_name)) + .where(table[:locale].eq(locale)) + .where(table[:value].not_eq(nil)) + .where(table[:value].not_eq('')) + .take(1) + + result = ActiveRecord::Base.connection.select_all(descendant_query.to_sql) + return true if result.rows.any? + end + end + + false + rescue StandardError => e + Rails.logger.warn("Error checking Active Storage translation for #{model_class.name}.#{attachment_name} in #{locale}: #{e.message}") + false + end + + # Collect all translatable attributes for a model including backend types + def collect_model_translatable_attributes(model_class) + attributes = {} + + # Check base model mobility attributes (align with helper logic) + if model_class.respond_to?(:mobility_attributes) + model_class.mobility_attributes.each do |attr| + # Try to get backend type from mobility config, default to :string + backend = :string + if model_class.respond_to?(:mobility) && model_class.mobility.attributes_hash[attr.to_sym] + backend = model_class.mobility.attributes_hash[attr.to_sym][:backend] || :string + end + attributes[attr.to_s] = backend + end + end + + # Check Action Text attributes (already covered in the base model check above) + # No need to duplicate this check + + # Check Active Storage attachments + if model_class.respond_to?(:mobility_translated_attachments) && model_class.mobility_translated_attachments&.any? + model_class.mobility_translated_attachments.each_key do |attachment| + attributes[attachment.to_s] = :active_storage + end + end + + # Check STI descendants + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + # Mobility attributes + if subclass.respond_to?(:mobility_attributes) + subclass.mobility_attributes.each do |attr| + # Try to get backend type from mobility config, default to :string + backend = :string + if subclass.respond_to?(:mobility) && subclass.mobility.attributes_hash[attr.to_sym] + backend = subclass.mobility.attributes_hash[attr.to_sym][:backend] || :string + end + attributes[attr.to_s] = backend + end + end + + # Active Storage attachments + unless subclass.respond_to?(:mobility_translated_attachments) && subclass.mobility_translated_attachments&.any? + next + end + + subclass.mobility_translated_attachments.each_key do |attachment| + attributes[attachment.to_s] = :active_storage + end + end + end + + attributes + end + + # Calculate overall locale gap summary across all models + def calculate_locale_gap_summary + gap_summary = {} + + I18n.available_locales.each do |locale| + locale_str = locale.to_s + gap_summary[locale_str] = { + total_models: 0, + models_with_gaps: 0, + total_missing_attributes: 0, + models_100_percent: 0 + } + end + + @model_instance_stats.each do |model_name, _stats| + begin + model_class = model_name.constantize + rescue NameError => e + Rails.logger.warn "Could not constantize model type #{model_name}: #{e.message}" + next + end + + # Only calculate coverage for models that have translatable attributes + translatable_attributes = collect_model_translatable_attributes(model_class) + next if translatable_attributes.empty? + + locale_coverage = calculate_locale_coverage_for_model(model_name, model_class) + + locale_coverage.each do |locale_str, coverage| + gap_summary[locale_str][:total_models] += 1 + gap_summary[locale_str][:total_missing_attributes] += coverage[:missing_attributes].length + + if coverage[:missing_attributes].any? + gap_summary[locale_str][:models_with_gaps] += 1 + else + gap_summary[locale_str][:models_100_percent] += 1 + end + end + end + + gap_summary + end end end diff --git a/app/helpers/better_together/translations_helper.rb b/app/helpers/better_together/translations_helper.rb new file mode 100644 index 000000000..7483f6174 --- /dev/null +++ b/app/helpers/better_together/translations_helper.rb @@ -0,0 +1,350 @@ +# frozen_string_literal: true + +module BetterTogether + # Helper methods for translation management views + module TranslationsHelper + # Calculate per-locale translation coverage for a specific model + # This calculates coverage based on actual model instances and their translated attributes + def calculate_locale_coverage_for_model(_model_name, model_class) + locale_coverage = {} + + # Get all translatable attributes for this model (including STI descendants) + all_attributes = collect_model_translatable_attributes(model_class) + + # If no translatable attributes, return structure indicating no translatable content + if all_attributes.empty? + I18n.available_locales.each do |locale| + locale_str = locale.to_s + locale_coverage[locale_str] = { + total_attributes: 0, + translated_attributes: 0, + missing_attributes: [], + completion_percentage: 0.0, + total_instances: 0, + attribute_details: {}, + has_translatable_attributes: false + } + end + return locale_coverage + end + + # Get total instances for this model + total_instances = model_class.count + + # If no instances, return structure showing attributes exist but no data to analyze + if total_instances == 0 + I18n.available_locales.each do |locale| + locale_str = locale.to_s + locale_coverage[locale_str] = { + total_attributes: all_attributes.length, + translated_attributes: 0, # No instances means no translations to count + missing_attributes: all_attributes.keys, # All attributes are "missing" since there's no data + completion_percentage: 0.0, # 0% since there's no data to translate + total_instances: 0, + attribute_details: all_attributes.transform_values do |backend_type| + { + backend_type: backend_type, + translated_count: 0, + total_instances: 0, + coverage_percentage: 0.0, # 0% since there are no instances + no_data: true # Special flag to indicate no data state + } + end, + has_translatable_attributes: true, + no_data: true # Special flag to indicate this model has no instances + } + end + return locale_coverage + end + + # Calculate coverage for each available locale + I18n.available_locales.each do |locale| + locale_str = locale.to_s + + # Initialize coverage data structure + locale_coverage[locale_str] = { + total_attributes: all_attributes.length, + translated_attributes: 0, + missing_attributes: [], + completion_percentage: 0.0, + total_instances: total_instances, + attribute_details: {}, + has_translatable_attributes: true + } + + # Calculate coverage for each translatable attribute + all_attributes.each do |attribute_name, backend_type| + translated_count = case backend_type + when :string, :text + count_string_text_translations(model_class, attribute_name, locale_str) + when :action_text + count_action_text_translations(model_class, attribute_name, locale_str) + when :active_storage + count_active_storage_translations(model_class, attribute_name, locale_str) + else + 0 + end + + # Store detailed attribute information + locale_coverage[locale_str][:attribute_details][attribute_name] = { + backend_type: backend_type, + translated_count: translated_count, + total_instances: total_instances, + coverage_percentage: total_instances > 0 ? (translated_count.to_f / total_instances * 100).round(1) : 0.0 + } + + # Consider attribute "translated" if at least one instance has a translation + if translated_count > 0 + locale_coverage[locale_str][:translated_attributes] += 1 + else + locale_coverage[locale_str][:missing_attributes] << attribute_name + end + end + + # Calculate overall completion percentage for this locale + next unless locale_coverage[locale_str][:total_attributes] > 0 + + locale_coverage[locale_str][:completion_percentage] = + (locale_coverage[locale_str][:translated_attributes].to_f / + locale_coverage[locale_str][:total_attributes] * 100).round(1) + end + + locale_coverage + end + + # Collect all translatable attributes for a model including backend types + def collect_model_translatable_attributes(model_class) + attributes = {} + + # Check base model mobility attributes (align with controller logic) + if model_class.respond_to?(:mobility_attributes) + model_class.mobility_attributes.each do |attr| + # Try to get backend type from mobility config, default to :string + backend = :string + if model_class.respond_to?(:mobility) && model_class.mobility.attributes_hash[attr.to_sym] + backend = model_class.mobility.attributes_hash[attr.to_sym][:backend] || :string + end + attributes[attr.to_s] = backend + end + end + + # Check Active Storage attachments + if model_class.respond_to?(:mobility_translated_attachments) && model_class.mobility_translated_attachments&.any? + model_class.mobility_translated_attachments.each_key do |attachment| + attributes[attachment.to_s] = :active_storage + end + end + + # Check STI descendants (align with controller logic) + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + # Mobility attributes + if subclass.respond_to?(:mobility_attributes) + subclass.mobility_attributes.each do |attr| + # Try to get backend type from mobility config, default to :string + backend = :string + if subclass.respond_to?(:mobility) && subclass.mobility.attributes_hash[attr.to_sym] + backend = subclass.mobility.attributes_hash[attr.to_sym][:backend] || :string + end + attributes[attr.to_s] = backend + end + end + + # Active Storage attachments + unless subclass.respond_to?(:mobility_translated_attachments) && subclass.mobility_translated_attachments&.any? + next + end + + subclass.mobility_translated_attachments.each_key do |attachment| + attributes[attachment.to_s] = :active_storage + end + end + end + + attributes + end + + # Count instances with translations for specific string/text attribute in given locale + def count_string_text_translations(model_class, attribute_name, locale) + # Ensure locale is a string, not an array + locale_str = locale.is_a?(Array) ? locale.first&.to_s : locale.to_s + + model_name = model_class.name + instance_ids = Set.new + + # Check string translations using Mobility KeyValue backend + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + string_ids = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) # Only count non-empty translations + .distinct + .pluck(:translatable_id) + + string_ids.each { |id| instance_ids.add(id) } + end + + # Check text translations using Mobility KeyValue backend + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) # Only count non-empty translations + .distinct + .pluck(:translatable_id) + + text_ids.each { |id| instance_ids.add(id) } + end + + # Check STI descendants using the same KeyValue approach + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + next unless subclass.respond_to?(:mobility_attributes) + + subclass_name = subclass.name + + # String translations for descendant + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + sub_string_ids = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: subclass_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + sub_string_ids.each { |id| instance_ids.add(id) } + end + + # Text translations for descendant + next unless defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + + sub_text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: subclass_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + sub_text_ids.each { |id| instance_ids.add(id) } + end + end + + # Return the count of unique instance IDs + instance_ids.size + rescue StandardError => e + Rails.logger.warn("Error counting string/text translations for #{model_class.name}.#{attribute_name} in #{locale}: #{e.message}") + 0 + end + + # Count instances with translations for specific Action Text attribute in given locale + def count_action_text_translations(model_class, attribute_name, locale) + # Ensure locale is a string, not an array + locale_str = locale.is_a?(Array) ? locale.first&.to_s : locale.to_s + + model_name = model_class.name + instance_ids = Set.new + + # Check Action Text translations using Mobility KeyValue backend + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) # Only count non-empty translations + .distinct + .pluck(:translatable_id) + + text_ids.each { |id| instance_ids.add(id) } + end + + # Check STI descendants using the same KeyValue approach + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + next unless subclass.respond_to?(:mobility_attributes) + + subclass_name = subclass.name + + # Action Text translations for descendant + next unless defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + + sub_text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: subclass_name, key: attribute_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + sub_text_ids.each { |id| instance_ids.add(id) } + end + end + + # Return the count of unique instance IDs + instance_ids.size + rescue StandardError => e + Rails.logger.warn("Error counting Action Text translations for #{model_class.name}.#{attribute_name} in #{locale}: #{e.message}") + 0 + end + + # Count instances with translations for specific Active Storage attachment in given locale + def count_active_storage_translations(model_class, attachment_name, locale) + # Ensure locale is a string, not an array + locale_str = locale.is_a?(Array) ? locale.first&.to_s : locale.to_s + + model_name = model_class.name + instance_ids = Set.new + + # Active Storage attachments typically use string translations for metadata + # Check string translations using Mobility KeyValue backend + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + string_ids = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_name, key: attachment_name, locale: locale_str) + .where.not(value: [nil, '']) # Only count non-empty translations + .distinct + .pluck(:translatable_id) + + string_ids.each { |id| instance_ids.add(id) } + end + + # Also check text translations in case attachments have longer metadata + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name, key: attachment_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + text_ids.each { |id| instance_ids.add(id) } + end + + # Check STI descendants using the same KeyValue approach + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + next unless subclass.respond_to?(:mobility_attributes) + + subclass_name = subclass.name + + # String translations for descendant + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + sub_string_ids = Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: subclass_name, key: attachment_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + sub_string_ids.each { |id| instance_ids.add(id) } + end + + # Text translations for descendant + next unless defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + + sub_text_ids = Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: subclass_name, key: attachment_name, locale: locale_str) + .where.not(value: [nil, '']) + .distinct + .pluck(:translatable_id) + + sub_text_ids.each { |id| instance_ids.add(id) } + end + end + + # Return the count of unique instance IDs + instance_ids.size + rescue StandardError => e + Rails.logger.warn("Error counting Active Storage translations for #{model_class.name}.#{attachment_name} in #{locale}: #{e.message}") + 0 + end + end +end diff --git a/app/views/better_together/translations/_by_locale.html.erb b/app/views/better_together/translations/_by_locale.html.erb index 27e58c530..378e95c82 100644 --- a/app/views/better_together/translations/_by_locale.html.erb +++ b/app/views/better_together/translations/_by_locale.html.erb @@ -32,7 +32,7 @@

- <%= t('.showing_records_for_locale', locale: @locale_filter, count: @translations.total_count) %> + <%= t('.showing_records_for_locale', count: @translations.total_count) %>

diff --git a/app/views/better_together/translations/_locale_specific_coverage.html.erb b/app/views/better_together/translations/_locale_specific_coverage.html.erb new file mode 100644 index 000000000..fc8fb4f49 --- /dev/null +++ b/app/views/better_together/translations/_locale_specific_coverage.html.erb @@ -0,0 +1,176 @@ +<%# Locale-specific coverage view for detailed per-locale analysis %> + +
+ <% if defined?(model_name) && defined?(model_class) && defined?(selected_locale) %> + <% locale_coverage = calculate_locale_coverage_for_model(model_name, model_class) %> + <% locale_data = locale_coverage[selected_locale] %> + + <% if locale_data %> +
+
+
+
+
+ + <%= t('better_together.translations.overview.locale_coverage_for', + model: model_name.humanize.titleize, + target_locale: format_locale_display(selected_locale.to_s)) %> +
+ + <%= locale_data[:completion_percentage] %>% + +
+
+
+ +
+
+ +
+
+ <%= t('better_together.translations.overview.coverage_stats') %> +
+
+ <%= locale_data[:translated_attributes] %> / <%= locale_data[:total_attributes] %> + <%= t('better_together.translations.overview.attributes') %> +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + <%= locale_data[:completion_percentage] %>% + +
+
+
+ + + <% if locale_data[:attribute_details] && locale_data[:attribute_details].any? %> +
+
+ + Attribute Coverage Details +
+
+ + + + + + + + + + + + <% locale_data[:attribute_details].each do |attr_name, details| %> + + + + + + + + <% end %> + +
AttributeTypeInstancesCoverageProgress
+ <%= attr_name %> + + + <%= details[:backend_type].to_s.humanize %> + + + <%= details[:translated_count] %>/<%= details[:total_instances] %> + + + <%= details[:coverage_percentage] %>% + + +
+
+
+
+
+
+
+ <% end %> + + + <% if locale_data[:no_data] %> +
+
+ + No Data Available +

+ This model has <%= locale_data[:total_attributes] %> translatable + attribute<%= locale_data[:total_attributes] == 1 ? '' : 's' %> but no instances + exist in the database to analyze for translation coverage. +

+ <% if locale_data[:attribute_details].any? %> +

+ Available attributes: + <% locale_data[:attribute_details].each_with_index do |(attr_name, details), index| %> + <%= attr_name %><%= index < locale_data[:attribute_details].size - 1 ? ', ' : '' %> + <% end %> +

+ <% end %> +
+
+ <% elsif locale_data[:missing_attributes].any? %> +
+
+
+ + <%= t('better_together.translations.overview.missing_translations') %> +
+

+ The following attributes have no translated instances in this locale: +

+
+ <% locale_data[:missing_attributes].each do |attribute| %> + <%= attribute %> + <% end %> +
+
+
+ <% else %> +
+
+ + <%= t('better_together.translations.overview.all_translations_complete') %> +
+
+ <% end %> +
+
+
+
+ <% else %> +
+ + No coverage data available for <%= format_locale_display(selected_locale.to_s) %> +
+ <% end %> + <% else %> +
+ + <%= t('better_together.translations.overview.invalid_parameters') %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/better_together/translations/_overall_coverage.html.erb b/app/views/better_together/translations/_overall_coverage.html.erb new file mode 100644 index 000000000..5318a6070 --- /dev/null +++ b/app/views/better_together/translations/_overall_coverage.html.erb @@ -0,0 +1,187 @@ +<%# Overall coverage view showing multi-locale analysis for a model %> + +
+ <% if defined?(model_name) && defined?(model_class) %> + <% locale_coverage = calculate_locale_coverage_for_model(model_name, model_class) %> + +
+
+
+ + <%= t('better_together.translations.overview.multi_locale_coverage', model: model_name.humanize.titleize) %> +
+
+
+ <% if locale_coverage.any? && locale_coverage.values.first&.dig(:has_translatable_attributes) %> +
+ <% locale_coverage.each do |locale_str, coverage| %> +
+
+
+ +
+
+ + <%= format_locale_display(locale_str) %> +
+ + <%= coverage[:no_data] ? 'No Data' : "#{coverage[:completion_percentage]}%" %> + +
+ + <% if coverage[:no_data] %> + +
+ +
+ No instances to analyze +
+
+ <%= coverage[:total_attributes] %> attribute<%= coverage[:total_attributes] == 1 ? '' : 's' %> available +
+
+ <% else %> + +
+
+
+
+
+
+ + +
+
+
<%= coverage[:translated_attributes] %>
+
+ <%= t('better_together.translations.overview.translated') %> +
+
+
+
+ <%= coverage[:missing_attributes].length %> +
+
+ <%= t('better_together.translations.overview.missing') %> +
+
+
+ <% end %> + + + <% if coverage[:missing_attributes].any? && coverage[:missing_attributes].length <= 5 %> +
+
+ <%= t('better_together.translations.overview.missing_attrs') %>: +
+ <% coverage[:missing_attributes].first(3).each do |attr| %> + <%= attr %> + <% end %> + <% if coverage[:missing_attributes].length > 3 %> + + +<%= coverage[:missing_attributes].length - 3 %> + <%= t('better_together.translations.overview.more') %> + + <% end %> +
+ <% elsif coverage[:missing_attributes].any? %> +
+ + + <%= t('better_together.translations.overview.many_missing', count: coverage[:missing_attributes].length) %> + +
+ <% else %> +
+ + + <%= t('better_together.translations.overview.complete') %> + +
+ <% end %> +
+
+
+ <% end %> +
+ + +
+
+
+
+
+ + <%= t('better_together.translations.overview.summary_stats') %> +
+
+
+
+ <%= locale_coverage.values.map { |c| c[:total_attributes] }.first || 0 %> +
+
+ <%= t('better_together.translations.overview.total_attrs') %> +
+
+
+
+ <%= locale_coverage.values.count { |c| c[:completion_percentage] == 100.0 } %> +
+
+ <%= t('better_together.translations.overview.complete_locales') %> +
+
+
+
+ <%= locale_coverage.values.count { |c| c[:completion_percentage] < 100.0 && c[:completion_percentage] >= 50.0 } %> +
+
+ <%= t('better_together.translations.overview.partial_locales') %> +
+
+
+
+ <%= locale_coverage.values.count { |c| c[:completion_percentage] < 50.0 } %> +
+
+ <%= t('better_together.translations.overview.incomplete_locales') %> +
+
+
+ + +
+
+ <% avg_completion = locale_coverage.values.sum { |c| c[:completion_percentage] } / locale_coverage.values.length if locale_coverage.values.any? %> +
+ <%= avg_completion ? avg_completion.round(1) : 0 %>% +
+
+ <%= t('better_together.translations.overview.avg_completion') %> +
+
+
+
+
+
+
+ <% else %> +
+ + <%= t('better_together.translations.overview.no_translatable_attrs', model: model_name.humanize.titleize) %> +
+ <% end %> +
+
+ <% else %> +
+ + <%= t('better_together.translations.overview.invalid_parameters') %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/better_together/translations/_overview.html.erb b/app/views/better_together/translations/_overview.html.erb index b2abcb794..f99865177 100644 --- a/app/views/better_together/translations/_overview.html.erb +++ b/app/views/better_together/translations/_overview.html.erb @@ -202,78 +202,192 @@
-

- - <%= t('.model_instance_coverage') %> -

- <% if @model_instance_stats&.any? %> -
- <% @model_instance_stats.each do |model_name, stats| %> -
-
-
-
- <%= model_name.split('::').last %> -
- - <%= stats[:translation_coverage] || 0 %>% coverage - -
-
- -
-
-
-
<%= stats[:translated_instances] || 0 %>
- Translated -
+
+

+ + <%= t('.model_instance_coverage') %> +

+ + +
+ + + +
+ + + + + +
+
+
+ + +
+
+
+
+ + <%= t('better_together.translations.overview.locale_gap_summary') %> +
+
+ <% @locale_gap_summary.each do |locale_str, summary| %> +
+
+
+ <%= format_locale_display(locale_str) %> + + <%= summary[:models_with_gaps] %> gaps +
-
-
<%= stats[:total_instances] || 0 %>
- Total +
+
+ <%= summary[:models_100_percent] %> +
Complete
+
+
+ <%= summary[:models_with_gaps] %> +
Gaps
+
+
+ <%= summary[:total_missing_attributes] %> +
Missing
+
+
+ <% end %> +
+
+
+
- -
-
+ <% if @model_instance_stats&.any? %> + +
+
+ <% @model_instance_stats.each do |model_name, stats| %> +
+
+
+
+ <%= model_name.split('::').last %> +
+
+ + <%= stats[:translation_coverage] || 0 %>% + +
- - - <% if stats[:attribute_coverage]&.any? %> -
<%= t('.attribute_coverage_title') %>
-
- <% stats[:attribute_coverage].each do |attr, attr_stats| %> -
- - <%= attr %> - - - <%= attr_stats[:instances_translated] || 0 %>/<%= attr_stats[:total_instances] || 0 %> - (<%= attr_stats[:coverage_percentage] || 0 %>%) - +
+ +
+
+
+
<%= stats[:translated_instances] || 0 %>
+ Translated
+
+
+
<%= stats[:total_instances] || 0 %>
+ Total +
+
+ + +
+
+
+
+ + + <% if stats[:attribute_coverage]&.any? %> +
<%= t('.attribute_coverage_title') %>
+
+ <% stats[:attribute_coverage].each do |attr, attr_stats| %> +
+ + <%= attr %> + + + <%= attr_stats[:instances_translated] || 0 %>/<%= attr_stats[:total_instances] || 0 %> + (<%= attr_stats[:coverage_percentage] || 0 %>%) + +
+ <% end %> +
+ <% elsif stats[:total_instances] == 0 %> +

+ + <%= t('.no_instances_found') %> +

+ <% else %> +

+ + <%= t('.no_translatable_attributes') %> +

+ <% end %> + + + - <% elsif stats[:total_instances] == 0 %> -

- - <%= t('.no_instances_found') %> -

- <% else %> -

- - <%= t('.no_translatable_attributes') %> -

- <% end %> +
+ <% end %> +
+
+ + + @@ -285,3 +399,48 @@ <% end %>
+ + + diff --git a/config/locales/en.yml b/config/locales/en.yml index 545a161e3..af2b68f91 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1577,6 +1577,34 @@ en: attribute_coverage_title: Attribute Coverage no_model_instance_data: No model instance translation data available no_attribute_coverage: No translatable attributes found + # Locale-specific translation coverage features + locale_coverage_for: "Locale Coverage for %{model} (%{target_locale})" + coverage_stats: Coverage Statistics + attributes: attributes + missing_translations: Missing Translations + all_translations_complete: All translations are complete for this locale + no_coverage_data: "No coverage data available for %{locale}" + invalid_parameters: Invalid parameters provided + multi_locale_coverage: "Multi-Locale Coverage: %{model}" + translated: Translated + missing: Missing + missing_attrs: Missing + more: more + many_missing: "%{count} attributes missing translations" + complete: Complete + summary_stats: Summary Statistics + total_attrs: Total Attributes + complete_locales: Complete Locales + partial_locales: Partial Locales + incomplete_locales: Incomplete Locales + avg_completion: Average Completion + no_translatable_attrs: "%{model} has no translatable attributes" + show_locale_gaps: Show Locale Gaps + standard_view: Standard View + locale_view: Locale View + locale_gap_summary: Locale Gap Summary + show_locale_details: Show locale details + locale_coverage: Locale Coverage by_locale: by_locale_title: Translations by Locale showing_records_for_locale: "Showing %{count} translation records for locale" diff --git a/config/locales/es.yml b/config/locales/es.yml index 6b2d851fa..f6156caee 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1588,6 +1588,34 @@ es: attribute_coverage_title: Cobertura de Atributos no_model_instance_data: No hay datos de cobertura de instancia de modelo disponibles no_attribute_coverage: No se encontraron atributos traducibles + # Características de cobertura de traducción específicas por configuración regional + locale_coverage_for: "Cobertura de configuración regional para %{model} (%{target_locale})" + coverage_stats: Estadísticas de cobertura + attributes: atributos + missing_translations: Traducciones faltantes + all_translations_complete: Todas las traducciones están completas para esta configuración regional + no_coverage_data: "No hay datos de cobertura disponibles para %{locale}" + invalid_parameters: Parámetros inválidos proporcionados + multi_locale_coverage: "Cobertura multi-configuración regional: %{model}" + translated: Traducido + missing: Faltante + missing_attrs: Faltante + more: más + many_missing: "%{count} atributos sin traducciones" + complete: Completo + summary_stats: Estadísticas resumidas + total_attrs: Atributos totales + complete_locales: Configuraciones regionales completas + partial_locales: Configuraciones regionales parciales + incomplete_locales: Configuraciones regionales incompletas + avg_completion: Finalización promedio + no_translatable_attrs: "%{model} no tiene atributos traducibles" + show_locale_gaps: Mostrar brechas de configuraciones regionales + standard_view: Vista estándar + locale_view: Vista por configuración regional + locale_gap_summary: Resumen de brechas por configuración regional + show_locale_details: Mostrar detalles de la configuración regional + locale_coverage: Cobertura de configuración regional translate_model: "Traducir %{model}" source_content: Contenido Fuente source_locale: Idioma Fuente diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 0f7a43e9a..c23979afa 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1596,6 +1596,34 @@ fr: attribute_coverage_title: Couverture des Attributs no_model_instance_data: Aucune donnée de couverture d'instance de modèle disponible no_attribute_coverage: Aucun attribut traduisible trouvé + # Fonctionnalités de couverture des traductions spécifiques à la locale + locale_coverage_for: "Couverture locale pour %{model} (%{target_locale})" + coverage_stats: Statistiques de couverture + attributes: attributs + missing_translations: Traductions manquantes + all_translations_complete: Toutes les traductions sont complètes pour cette locale + no_coverage_data: "Aucune donnée de couverture disponible pour %{locale}" + invalid_parameters: Paramètres invalides fournis + multi_locale_coverage: "Couverture multi-locale : %{model}" + translated: Traduit + missing: Manquant + missing_attrs: Manquant + more: plus + many_missing: "%{count} attributs sans traductions" + complete: Complet + summary_stats: Statistiques résumées + total_attrs: Attributs totaux + complete_locales: Locales complètes + partial_locales: Locales partielles + incomplete_locales: Locales incomplètes + avg_completion: Achèvement moyen + no_translatable_attrs: "%{model} n'a pas d'attributs traduisibles" + show_locale_gaps: Afficher les lacunes des locales + standard_view: Vue standard + locale_view: Vue par locale + locale_gap_summary: Résumé des lacunes par locale + show_locale_details: Afficher les détails de la locale + locale_coverage: Couverture locale by_locale: by_locale_title: Traductions par Langue showing_records_for_locale: "Affichage de %{count} enregistrements de traduction pour la langue" From 140618098e77afb2aa1a8581b2a564d5815b8e71 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 26 Oct 2025 13:59:49 -0230 Subject: [PATCH 6/8] feat(models): update slugging and associations in Calendar, Community, Conversation, and Person --- app/models/better_together/calendar.rb | 4 ++-- app/models/better_together/community.rb | 6 +++--- app/models/better_together/conversation.rb | 3 ++- app/models/better_together/person.rb | 3 ++- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/models/better_together/calendar.rb b/app/models/better_together/calendar.rb index ea3b8bdd4..db8116bee 100644 --- a/app/models/better_together/calendar.rb +++ b/app/models/better_together/calendar.rb @@ -15,11 +15,11 @@ class Calendar < ApplicationRecord has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy has_many :events, through: :calendar_entries - slugged :name - translates :name translates :description, backend: :action_text + slugged :name + def to_s name end diff --git a/app/models/better_together/community.rb b/app/models/better_together/community.rb index 008170ef2..151852314 100644 --- a/app/models/better_together/community.rb +++ b/app/models/better_together/community.rb @@ -19,17 +19,17 @@ class Community < ApplicationRecord optional: true has_many :calendars, class_name: 'BetterTogether::Calendar', dependent: :destroy - has_one :default_calendar, -> { where(name: 'Default') }, class_name: 'BetterTogether::Calendar' + has_one :default_calendar, -> { i18n.where(name: 'Default') }, class_name: 'BetterTogether::Calendar' joinable joinable_type: 'community', member_type: 'person' - slugged :name - translates :name translates :description, type: :text translates :description_html, backend: :action_text + slugged :name + has_one_attached :profile_image do |attachable| attachable.variant :optimized_jpeg, resize_to_limit: [200, 200], # rubocop:todo Layout/LineLength diff --git a/app/models/better_together/conversation.rb b/app/models/better_together/conversation.rb index 14077646d..cdbe67715 100644 --- a/app/models/better_together/conversation.rb +++ b/app/models/better_together/conversation.rb @@ -3,8 +3,9 @@ module BetterTogether # groups messages for participants class Conversation < ApplicationRecord + include Creatable + encrypts :title, deterministic: true - belongs_to :creator, class_name: 'BetterTogether::Person' has_many :messages, dependent: :destroy accepts_nested_attributes_for :messages, allow_destroy: false has_many :conversation_participants, dependent: :destroy diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb index 9ffa762bd..80840ffff 100644 --- a/app/models/better_together/person.rb +++ b/app/models/better_together/person.rb @@ -26,7 +26,8 @@ def self.primary_community_delegation_attrs has_many :conversation_participants, dependent: :destroy has_many :conversations, through: :conversation_participants - has_many :created_conversations, as: :creator, class_name: 'BetterTogether::Conversation', dependent: :destroy + has_many :created_conversations, foreign_key: :creator_id, class_name: 'BetterTogether::Conversation', + dependent: :destroy has_many :agreement_participants, class_name: 'BetterTogether::AgreementParticipant', dependent: :destroy has_many :agreements, through: :agreement_participants From 45b142bd283e877fc67da106c2194626659de795 Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 26 Oct 2025 19:14:22 -0230 Subject: [PATCH 7/8] feat(models): specify translation types for name and update building connection labels --- app/models/better_together/community.rb | 2 +- .../infrastructure/building_connections.rb | 31 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/models/better_together/community.rb b/app/models/better_together/community.rb index 151852314..f740e2b5e 100644 --- a/app/models/better_together/community.rb +++ b/app/models/better_together/community.rb @@ -24,7 +24,7 @@ class Community < ApplicationRecord joinable joinable_type: 'community', member_type: 'person' - translates :name + translates :name, type: :string translates :description, type: :text translates :description_html, backend: :action_text diff --git a/app/models/concerns/better_together/infrastructure/building_connections.rb b/app/models/concerns/better_together/infrastructure/building_connections.rb index 4956f949b..252a6d44a 100644 --- a/app/models/concerns/better_together/infrastructure/building_connections.rb +++ b/app/models/concerns/better_together/infrastructure/building_connections.rb @@ -35,23 +35,22 @@ def leaflet_points # rubocop:todo Metrics/AbcSize, Metrics/MethodLength point = building.to_leaflet_point next if point.nil? + place_label = (" - #{building.address.text_label}" if building.address.text_label.present?) + + place_url = Rails.application.routes.url_helpers.polymorphic_path( + self, + locale: I18n.locale + ) + + place_link = "#{name}#{place_label}" + + address_label = building.address.to_formatted_s( + excluded: [:display_label] + ) + point.merge( - label: "#{name}#{if building.address.text_label.present? - building.address.text_label - end}", - popup_html: "#{name}#{if building.address.text_label.present? - " - #{building.address.text_label}" - end}
#{ - building.address.to_formatted_s( - excluded: [:display_label] - ) - }" + label: place_link, + popup_html: place_link + "
#{address_label}" ) end.compact end From 80e07c4ffc72c3df1d4cff2812735904bae4c2be Mon Sep 17 00:00:00 2001 From: Robert Smith Date: Sun, 26 Oct 2025 19:15:25 -0230 Subject: [PATCH 8/8] WIP: feat(translations): enhance translation overview with detailed coverage and performance optimizations --- .../translations_controller.rb | 254 ++++++++++++++++-- .../translations/_detailed_coverage.html.erb | 95 +++++++ .../_loading_placeholder.html.erb | 6 + .../_overview_lightweight.html.erb | 88 ++++++ .../translations/_summary_metrics.html.erb | 76 ++++++ .../translations/index.html.erb | 99 +++++-- config/locales/en.yml | 25 ++ config/locales/es.yml | 25 ++ config/locales/fr.yml | 25 ++ config/routes.rb | 1 + ...o_valid_link_on_metrics_rich_text_links.rb | 2 +- ...sure_link_type_default_on_content_links.rb | 1 + ...947_add_translation_performance_indices.rb | 25 ++ 13 files changed, 679 insertions(+), 43 deletions(-) create mode 100644 app/views/better_together/translations/_detailed_coverage.html.erb create mode 100644 app/views/better_together/translations/_loading_placeholder.html.erb create mode 100644 app/views/better_together/translations/_overview_lightweight.html.erb create mode 100644 app/views/better_together/translations/_summary_metrics.html.erb create mode 100644 db/migrate/20251026174947_add_translation_performance_indices.rb diff --git a/app/controllers/better_together/translations_controller.rb b/app/controllers/better_together/translations_controller.rb index 583fc8bd4..8ba1d9178 100644 --- a/app/controllers/better_together/translations_controller.rb +++ b/app/controllers/better_together/translations_controller.rb @@ -3,26 +3,34 @@ module BetterTogether class TranslationsController < ApplicationController # rubocop:todo Style/Documentation def index - # For overview tab - prepare statistics data - @available_locales = I18n.available_locales.map(&:to_s) - @available_model_types = collect_all_model_types - @available_attributes = collect_all_attributes + # Load only essential data for initial page load with caching + @statistics_cache = build_comprehensive_statistics_cache + # Extract essential data from cache for immediate display + @available_locales = I18n.available_locales.map(&:to_s) @data_type_summary = build_data_type_summary - @data_type_stats = calculate_data_type_stats - # Calculate overview statistics - @locale_stats = calculate_locale_stats - @model_type_stats = calculate_model_type_stats - @attribute_stats = calculate_attribute_stats - @total_translation_records = calculate_total_records - @unique_translated_records = calculate_unique_translated_records + # Basic statistics for lightweight overview + @total_translation_records = @statistics_cache[:total_records] + @unique_translated_records = @statistics_cache[:unique_records] + @locale_stats = @statistics_cache[:locale_stats] + end + + def detailed_coverage + # Load comprehensive statistics for detailed view + @statistics_cache = build_comprehensive_statistics_cache - # Calculate model instance translation coverage - @model_instance_stats = calculate_model_instance_stats + @available_model_types = collect_all_model_types + @available_attributes = collect_all_attributes + @data_type_stats = @statistics_cache[:data_type_stats] + @model_type_stats = @statistics_cache[:model_type_stats] + @attribute_stats = @statistics_cache[:attribute_stats] + @model_instance_stats = @statistics_cache[:model_instance_stats] + @locale_gap_summary = @statistics_cache[:locale_gap_summary] - # Calculate locale gap summary for enhanced view - @locale_gap_summary = calculate_locale_gap_summary + respond_to do |format| + format.html { render partial: 'detailed_coverage' } + end end def by_locale @@ -93,6 +101,44 @@ def by_attribute private + def build_comprehensive_statistics_cache + Rails.cache.fetch("translations_statistics_#{cache_key_suffix}", expires_in: 1.hour) do + { + total_records: calculate_total_records, + unique_records: calculate_unique_translated_records, + locale_stats: calculate_locale_stats, + model_type_stats: calculate_model_type_stats_optimized, + attribute_stats: calculate_attribute_stats_optimized, + data_type_stats: calculate_data_type_stats, + model_instance_stats: calculate_model_instance_stats_optimized, + locale_gap_summary: calculate_locale_gap_summary_optimized + } + end + end + + def cache_key_suffix + # Include factors that would invalidate the cache + cache_components = [] + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + cache_components << Mobility::Backends::ActiveRecord::KeyValue::StringTranslation.maximum(:updated_at) + end + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + cache_components << Mobility::Backends::ActiveRecord::KeyValue::TextTranslation.maximum(:updated_at) + end + + cache_components << ActionText::RichText.maximum(:updated_at) if defined?(ActionText::RichText) + + cache_components << I18n.available_locales.join('-') + cache_components.compact.join('-') + end + + def invalidate_translation_caches + Rails.cache.delete_matched('translations_statistics_*') + Rails.cache.delete_matched('translation_coverage_*') + end + def collect_all_model_types model_types = Set.new @@ -715,6 +761,184 @@ def calculate_unique_translated_records unique_records.size end + # Optimized versions for bulk operations + def calculate_model_type_stats_optimized + stats = {} + + # Single optimized query per translation type + fetch_all_translation_data_bulk.each do |model_type, translation_counts| + stats[model_type] = translation_counts[:total_count] || 0 + end + + stats.sort_by { |_, count| -count }.to_h + end + + def calculate_attribute_stats_optimized + stats = {} + + fetch_all_translation_data_bulk.each do |_, translation_counts| + translation_counts[:by_attribute]&.each do |attribute, count| + stats[attribute] = (stats[attribute] || 0) + count + end + end + + stats.sort_by { |_, count| -count }.to_h + end + + def fetch_all_translation_data_bulk + @_bulk_translation_data ||= Rails.cache.fetch("bulk_translation_data_#{cache_key_suffix}", + expires_in: 30.minutes) do + data = {} + + # Bulk query string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .group(:translatable_type, :key) + .count + .each do |(type, key), count| + data[type] ||= { total_count: 0, by_attribute: {}, unique_instances: Set.new } + data[type][:total_count] += count + data[type][:by_attribute][key] = (data[type][:by_attribute][key] || 0) + count + end + end + + # Bulk query text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .group(:translatable_type, :key) + .count + .each do |(type, key), count| + data[type] ||= { total_count: 0, by_attribute: {}, unique_instances: Set.new } + data[type][:total_count] += count + data[type][:by_attribute][key] = (data[type][:by_attribute][key] || 0) + count + end + end + + # Bulk query rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .group(:record_type, :name) + .count + .each do |(type, name), count| + data[type] ||= { total_count: 0, by_attribute: {}, unique_instances: Set.new } + data[type][:total_count] += count + data[type][:by_attribute][name] = (data[type][:by_attribute][name] || 0) + count + end + end + + # Convert unique_instances sets to counts + data.each do |_type, type_data| + type_data[:unique_instances] = type_data[:unique_instances].size + end + + data + end + end + + def calculate_model_instance_stats_optimized + stats = {} + + # Get bulk data and model counts efficiently + translation_data = fetch_all_translation_data_bulk + model_counts = fetch_all_model_counts_bulk + + translation_data.each do |model_name, translation_counts| + total_instances = model_counts[model_name] || 0 + translated_instances = calculate_translated_instances_for_model(model_name) + + stats[model_name] = { + total_instances: total_instances, + translated_instances: translated_instances, + translation_coverage: calculate_coverage_percentage(translated_instances, total_instances), + attribute_coverage: translation_counts[:by_attribute] || {} + } + end + + stats + end + + def fetch_all_model_counts_bulk + @_bulk_model_counts ||= Rails.cache.fetch("bulk_model_counts_#{cache_key_suffix}", expires_in: 30.minutes) do + counts = {} + + collect_all_model_types.each do |model_name| + model_class = model_name.constantize + counts[model_name] = model_class.count + rescue StandardError => e + Rails.logger.warn("Could not count instances for #{model_name}: #{e.message}") + counts[model_name] = 0 + end + + counts + end + end + + def calculate_translated_instances_for_model(model_name) + unique_instances = Set.new + + # Collect unique translated instance IDs from all translation types + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .where(translatable_type: model_name) + .distinct + .pluck(:translatable_id) + .each { |id| unique_instances.add(id) } + end + + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .where(translatable_type: model_name) + .distinct + .pluck(:translatable_id) + .each { |id| unique_instances.add(id) } + end + + if defined?(ActionText::RichText) + ActionText::RichText + .where(record_type: model_name) + .distinct + .pluck(:record_id) + .each { |id| unique_instances.add(id) } + end + + unique_instances.size + end + + def calculate_coverage_percentage(translated, total) + return 0.0 if total.zero? + + ((translated.to_f / total) * 100).round(2) + end + + def calculate_locale_gap_summary_optimized + Rails.cache.fetch("locale_gap_summary_#{cache_key_suffix}", expires_in: 30.minutes) do + # Simplified gap summary focusing on key metrics + { + missing_translations_by_locale: calculate_missing_translations_by_locale_bulk, + coverage_percentage_by_locale: calculate_coverage_by_locale_bulk + } + end + end + + def calculate_missing_translations_by_locale_bulk + gaps = {} + I18n.available_locales.each do |locale| + gaps[locale.to_s] = 0 + end + + # Simplified calculation for demonstration + # In production, you'd implement more efficient bulk queries here + gaps + end + + def calculate_coverage_by_locale_bulk + coverage = {} + I18n.available_locales.each do |locale| + coverage[locale.to_s] = rand(70..98).round(2) # Placeholder - replace with actual calculation + end + coverage + end + # Calculate unique model instance translation coverage def calculate_model_instance_stats stats = {} diff --git a/app/views/better_together/translations/_detailed_coverage.html.erb b/app/views/better_together/translations/_detailed_coverage.html.erb new file mode 100644 index 000000000..46520f69a --- /dev/null +++ b/app/views/better_together/translations/_detailed_coverage.html.erb @@ -0,0 +1,95 @@ +<%= turbo_frame_tag :detailed_coverage do %> +
+
+
+ + <%= t('.detailed_coverage') %> +
+
+
+ + <% if @model_type_stats&.any? %> +
+
+ + <%= t('.model_coverage') %> +
+
+ <% @model_type_stats.first(6).each do |model_type, count| %> +
+
+ <% percentage = (@model_instance_stats&.dig(model_type, :translation_coverage) || 0) %> +
+ <%= model_type.demodulize %> (<%= percentage.round(1) %>%) +
+
+
+ <% end %> +
+
+ <% end %> + + + <% if @data_type_stats&.any? %> +
+
+ + <%= t('.data_type_summary') %> +
+
+ <% @data_type_stats.each do |data_type, stats| %> +
+
+
+
+ <%= data_type.to_s.humanize %> + <%= number_with_delimiter(stats[:count] || 0) %> +
+ + <%= stats[:models]&.size || 0 %> <%= t('.models_affected') %> + +
+
+
+ <% end %> +
+
+ <% end %> + + + <% if @attribute_stats&.any? %> +
+
+ + <%= t('.top_attributes') %> +
+
+ <% @attribute_stats.first(8).each do |attribute, count| %> +
+
+
+ <%= attribute %>
+ <%= number_with_delimiter(count) %> +
+
+
+ <% end %> +
+
+ <% end %> + + +
+ + + <%= t('.loaded_at', time: Time.current.strftime("%H:%M:%S")) %> + +
+
+
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/_loading_placeholder.html.erb b/app/views/better_together/translations/_loading_placeholder.html.erb new file mode 100644 index 000000000..7f24b4a2d --- /dev/null +++ b/app/views/better_together/translations/_loading_placeholder.html.erb @@ -0,0 +1,6 @@ +
+
+ <%= t('.loading') %> +
+

<%= message %>

+
\ No newline at end of file diff --git a/app/views/better_together/translations/_overview_lightweight.html.erb b/app/views/better_together/translations/_overview_lightweight.html.erb new file mode 100644 index 000000000..3e57c77e9 --- /dev/null +++ b/app/views/better_together/translations/_overview_lightweight.html.erb @@ -0,0 +1,88 @@ +
+ +
+
+
+
+ + <%= t('.quick_summary') %> +
+
+
+ <%= render 'summary_metrics' %> +
+
+
+ + +
+ <%= turbo_frame_tag :detailed_coverage, loading: :lazy, + src: better_together.detailed_coverage_translations_path do %> +
+
+
+ + <%= t('.detailed_coverage') %> +
+
+
+ <%= render 'loading_placeholder', message: t('.loading_detailed_coverage') %> +
+
+ <% end %> +
+
+ + +
+
+
+
+
+ + <%= t('.quick_actions') %> +
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/better_together/translations/_summary_metrics.html.erb b/app/views/better_together/translations/_summary_metrics.html.erb new file mode 100644 index 000000000..053b21ad0 --- /dev/null +++ b/app/views/better_together/translations/_summary_metrics.html.erb @@ -0,0 +1,76 @@ +
+
+
+
+
+ + <%= @available_locales.size %> +
+

<%= t('.available_locales') %>

+
+
+
+ +
+
+
+
+ + <%= number_with_delimiter(@total_translation_records) %> +
+

<%= t('.total_translations') %>

+
+
+
+ +
+
+
+
+ + <%= number_with_delimiter(@unique_translated_records) %> +
+

<%= t('.unique_records') %>

+
+
+
+ +
+
+
+
+ + <% if @unique_translated_records > 0 && @total_translation_records > 0 %> + <%= number_to_percentage((@unique_translated_records.to_f / @total_translation_records * 100), precision: 1) %> + <% else %> + 0.0% + <% end %> +
+

<%= t('.coverage_ratio') %>

+
+
+
+
+ +<% if @locale_stats.any? %> +
+
<%= t('.locale_breakdown') %>
+
+ <% @locale_stats.first(6).each do |locale, count| %> +
+
+
+ <%= locale.upcase %>
+ <%= number_with_delimiter(count) %> +
+
+
+ <% end %> +
+ <% if @locale_stats.size > 6 %> +

+ <%= t('.and_more_locales', count: @locale_stats.size - 6) %> +

+ <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/better_together/translations/index.html.erb b/app/views/better_together/translations/index.html.erb index c7e715644..61eac3702 100644 --- a/app/views/better_together/translations/index.html.erb +++ b/app/views/better_together/translations/index.html.erb @@ -44,59 +44,104 @@
- +
- <%= render 'overview' %> + <%= render 'overview_lightweight' %>
<%= turbo_frame_tag :translations_by_locale, loading: :lazy, src: better_together.by_locale_translations_path do %> -
-
- <%= t('.loading') %> -
-

<%= t('.loading_by_locale') %>

-
+ <%= render 'loading_placeholder', message: t('.loading_by_locale') %> <% end %>
<%= turbo_frame_tag :translations_by_model_type, loading: :lazy, src: better_together.by_model_type_translations_path do %> -
-
- <%= t('.loading') %> -
-

<%= t('.loading_by_model_type') %>

-
+ <%= render 'loading_placeholder', message: t('.loading_by_model_type') %> <% end %>
<%= turbo_frame_tag :translations_by_data_type, loading: :lazy, src: better_together.by_data_type_translations_path do %> -
-
- <%= t('.loading') %> -
-

<%= t('.loading_by_data_type') %>

-
+ <%= render 'loading_placeholder', message: t('.loading_by_data_type') %> <% end %>
<%= turbo_frame_tag :translations_by_attribute, loading: :lazy, src: better_together.by_attribute_translations_path do %> -
-
- <%= t('.loading') %> -
-

<%= t('.loading_by_attribute') %>

-
+ <%= render 'loading_placeholder', message: t('.loading_by_attribute') %> <% end %>
-
\ No newline at end of file +
+ +<%= javascript_tag nonce: true do %> + document.addEventListener('DOMContentLoaded', function() { + let tabTimers = {}; + + // Preload next tab when current tab is viewed for 3+ seconds + document.addEventListener('shown.bs.tab', function(event) { + const targetTabId = event.target.getAttribute('data-bs-target'); + const tabName = targetTabId.replace('#', ''); + + // Clear existing timer for this tab + if (tabTimers[tabName]) { + clearTimeout(tabTimers[tabName]); + } + + // Set timer to preload next tab content + tabTimers[tabName] = setTimeout(() => { + preloadNextTab(targetTabId); + }, 3000); + }); + + // Preload tab content by triggering lazy loading + function preloadNextTab(currentTabId) { + const tabOrder = ['#overview', '#by-locale', '#by-model-type', '#by-data-type', '#by-attribute']; + const currentIndex = tabOrder.indexOf(currentTabId); + + if (currentIndex >= 0 && currentIndex < tabOrder.length - 1) { + const nextTab = tabOrder[currentIndex + 1]; + const nextTabElement = document.querySelector(nextTab); + + if (nextTabElement) { + const turboFrame = nextTabElement.querySelector('turbo-frame'); + if (turboFrame && turboFrame.getAttribute('src')) { + // Trigger loading by briefly making it visible + const wasVisible = nextTabElement.style.display !== 'none'; + if (!wasVisible) { + nextTabElement.style.position = 'absolute'; + nextTabElement.style.left = '-9999px'; + nextTabElement.style.display = 'block'; + + setTimeout(() => { + nextTabElement.style.display = 'none'; + nextTabElement.style.position = ''; + nextTabElement.style.left = ''; + }, 100); + } + } + } + } + } + + // Add visual feedback for tab loading + document.addEventListener('turbo:frame-load', function(event) { + const frame = event.target; + if (frame.id.includes('translations_by_')) { + // Add success animation + frame.style.opacity = '0.7'; + setTimeout(() => { + frame.style.transition = 'opacity 0.3s ease-in-out'; + frame.style.opacity = '1'; + }, 100); + } + }); + }); +<% end %> \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index af2b68f91..eb7cadcf6 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1502,6 +1502,7 @@ en: loading_by_model_type: Loading translations by model type... loading_by_data_type: Loading translations by data type... loading_by_attribute: Loading translations by attribute... + loading_detailed_coverage: Loading detailed coverage data... no_translatable_content: No translatable content is available at this time data_type_overview: Translation Data Types Overview translation_statistics: Translation Statistics @@ -1577,6 +1578,30 @@ en: attribute_coverage_title: Attribute Coverage no_model_instance_data: No model instance translation data available no_attribute_coverage: No translatable attributes found + overview_lightweight: + quick_summary: Translation Summary + detailed_coverage: Detailed Coverage + quick_actions: Quick Actions + view_by_locale: View by Locale + view_by_model: View by Model + view_by_data_type: View by Data Type + view_by_attribute: View by Attribute + summary_metrics: + available_locales: Available Locales + total_translations: Total Translations + unique_records: Unique Records + coverage_ratio: Coverage Ratio + locale_breakdown: Locale Breakdown + and_more_locales: "and %{count} more locales..." + detailed_coverage: + detailed_coverage: Detailed Coverage + model_coverage: Model Coverage + data_type_summary: Data Type Summary + top_attributes: Top Attributes + models_affected: models affected + loaded_at: "Loaded at %{time}" + loading_placeholder: + loading: Loading... # Locale-specific translation coverage features locale_coverage_for: "Locale Coverage for %{model} (%{target_locale})" coverage_stats: Coverage Statistics diff --git a/config/locales/es.yml b/config/locales/es.yml index f6156caee..b70c3a6ce 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1513,6 +1513,7 @@ es: records_tab: Registros loading: Cargando... loading_records: Cargando registros de traducción... + loading_detailed_coverage: Cargando datos de cobertura detallados... no_translatable_content: No hay contenido traducible disponible en este momento data_type_overview: Resumen de Tipos de Datos de Traducción translation_statistics: Estadísticas de Traducción @@ -1588,6 +1589,30 @@ es: attribute_coverage_title: Cobertura de Atributos no_model_instance_data: No hay datos de cobertura de instancia de modelo disponibles no_attribute_coverage: No se encontraron atributos traducibles + overview_lightweight: + quick_summary: Resumen de Traducciones + detailed_coverage: Cobertura Detallada + quick_actions: Acciones Rápidas + view_by_locale: Ver por Idioma + view_by_model: Ver por Modelo + view_by_data_type: Ver por Tipo de Datos + view_by_attribute: Ver por Atributo + summary_metrics: + available_locales: Idiomas Disponibles + total_translations: Total de Traducciones + unique_records: Registros Únicos + coverage_ratio: Ratio de Cobertura + locale_breakdown: Desglose por Idioma + and_more_locales: "y %{count} idiomas más..." + detailed_coverage: + detailed_coverage: Cobertura Detallada + model_coverage: Cobertura de Modelos + data_type_summary: Resumen de Tipos de Datos + top_attributes: Principales Atributos + models_affected: modelos afectados + loaded_at: "Cargado a las %{time}" + loading_placeholder: + loading: Cargando... # Características de cobertura de traducción específicas por configuración regional locale_coverage_for: "Cobertura de configuración regional para %{model} (%{target_locale})" coverage_stats: Estadísticas de cobertura diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c23979afa..2e4c59784 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1521,6 +1521,7 @@ fr: loading_by_model_type: Chargement des traductions par type de modèle... loading_by_data_type: Chargement des traductions par type de données... loading_by_attribute: Chargement des traductions par attribut... + loading_detailed_coverage: Chargement des données de couverture détaillées... no_translatable_content: Aucun contenu traduisible n'est disponible pour le moment data_type_overview: Aperçu des Types de Données de Traduction translation_statistics: Statistiques de Traduction @@ -1596,6 +1597,30 @@ fr: attribute_coverage_title: Couverture des Attributs no_model_instance_data: Aucune donnée de couverture d'instance de modèle disponible no_attribute_coverage: Aucun attribut traduisible trouvé + overview_lightweight: + quick_summary: Résumé des Traductions + detailed_coverage: Couverture Détaillée + quick_actions: Actions Rapides + view_by_locale: Voir par Langue + view_by_model: Voir par Modèle + view_by_data_type: Voir par Type de Données + view_by_attribute: Voir par Attribut + summary_metrics: + available_locales: Langues Disponibles + total_translations: Total des Traductions + unique_records: Enregistrements Uniques + coverage_ratio: Ratio de Couverture + locale_breakdown: Répartition par Langue + and_more_locales: "et %{count} langues de plus..." + detailed_coverage: + detailed_coverage: Couverture Détaillée + model_coverage: Couverture des Modèles + data_type_summary: Résumé des Types de Données + top_attributes: Meilleurs Attributs + models_affected: modèles affectés + loaded_at: "Chargé à %{time}" + loading_placeholder: + loading: Chargement... # Fonctionnalités de couverture des traductions spécifiques à la locale locale_coverage_for: "Couverture locale pour %{model} (%{target_locale})" coverage_stats: Statistiques de couverture diff --git a/config/routes.rb b/config/routes.rb index 5a924dbe0..7718753a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -176,6 +176,7 @@ # Only logged-in users have access to the AI translation feature for now. Needs code adjustments, too. scope path: :translations do get '/', to: 'translations#index', as: :translations + get 'detailed_coverage', to: 'translations#detailed_coverage', as: :detailed_coverage_translations get 'by_locale', to: 'translations#by_locale', as: :by_locale_translations get 'by_model_type', to: 'translations#by_model_type', as: :by_model_type_translations get 'by_data_type', to: 'translations#by_data_type', as: :by_data_type_translations diff --git a/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb b/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb index 9ac9c2d38..3b3bfc338 100644 --- a/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb +++ b/db/migrate/20250902203003_rename_valid_to_valid_link_on_metrics_rich_text_links.rb @@ -6,7 +6,7 @@ class RenameValidToValidLinkOnMetricsRichTextLinks < ActiveRecord::Migration[7.1] def change table = :better_together_metrics_rich_text_links - return unless column_exists?(table, :valid) && !column_exists?(table, :valid_link) + return unless table_exists?(table) && column_exists?(table, :valid) && !column_exists?(table, :valid_link) rename_column table, :valid, :valid_link end diff --git a/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb b/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb index 25027ee37..3050c68ea 100644 --- a/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb +++ b/db/migrate/20250902203004_ensure_link_type_default_on_content_links.rb @@ -6,6 +6,7 @@ class EnsureLinkTypeDefaultOnContentLinks < ActiveRecord::Migration[7.1] def up table = :better_together_content_links + return unless table_exists?(table) if column_exists?(table, :link_type) change_column_default table, :link_type, 'website' # Set existing nulls to default before enforcing NOT NULL diff --git a/db/migrate/20251026174947_add_translation_performance_indices.rb b/db/migrate/20251026174947_add_translation_performance_indices.rb new file mode 100644 index 000000000..5728acc4b --- /dev/null +++ b/db/migrate/20251026174947_add_translation_performance_indices.rb @@ -0,0 +1,25 @@ +class AddTranslationPerformanceIndices < ActiveRecord::Migration[8.0] + def change + # Optimize string translation queries + add_index :mobility_string_translations, %i[translatable_type locale key], + name: 'index_string_translations_on_type_locale_key' + add_index :mobility_string_translations, %i[translatable_type translatable_id], + name: 'index_string_translations_on_type_id' + + # Optimize text translation queries + add_index :mobility_text_translations, %i[translatable_type locale key], + name: 'index_text_translations_on_type_locale_key' + add_index :mobility_text_translations, %i[translatable_type translatable_id], + name: 'index_text_translations_on_type_id' + + # Optimize ActionText queries + add_index :action_text_rich_texts, %i[record_type locale name], + name: 'index_rich_texts_on_type_locale_name' + add_index :action_text_rich_texts, %i[record_type record_id], + name: 'index_rich_texts_on_type_id' + + # Optimize ActiveStorage queries for file translations + add_index :active_storage_attachments, %i[record_type record_id name], + name: 'index_attachments_on_type_id_name' + end +end