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 db50debbd..8ba1d9178 100644 --- a/app/controllers/better_together/translations_controller.rb +++ b/app/controllers/better_together/translations_controller.rb @@ -2,23 +2,1863 @@ module BetterTogether class TranslationsController < ApplicationController # rubocop:todo Style/Documentation + def index + # 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 + + # 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 + + @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] + + respond_to do |format| + format.html { render partial: 'detailed_coverage' } + end + 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 + + translation_records = fetch_translation_records_by_attribute(@attribute_filter) + @translations = Kaminari.paginate_array(translation_records).page(@page).per(100) + + respond_to do |format| + format.html { render partial: 'by_attribute' } + end + end + + 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 + + # 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, constantize, and filter for models with actual translatable attributes + model_types.map do |type_name| + model_class = type_name.constantize + + # Load subclasses to ensure STI descendants are available + load_subclasses(model_class) + + # Check if the model actually has translatable attributes + has_translatable_attributes = has_translatable_attributes?(model_class) + + if has_translatable_attributes + { name: type_name, class: model_class } + else + Rails.logger.debug "Skipping #{type_name}: no translatable attributes found" + nil + end + 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 from the model itself + 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 from the model itself + 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 + + # For STI models, also check descendants for translatable attributes + load_subclasses(model_class) + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + # Add mobility attributes from subclass + if subclass.respond_to?(:mobility_attributes) + subclass.mobility_attributes.each do |attr| + attributes << { name: attr.to_s, type: 'text', source: 'mobility' } unless attributes.any? do |a| + a[:name] == attr.to_s + end + end + end + + # Add translatable attachment attributes from subclass + next unless subclass.respond_to?(:mobility_translated_attachments) + + subclass.mobility_translated_attachments&.keys&.each do |attr| + attributes << { name: attr.to_s, type: 'file', source: 'attachment' } unless attributes.any? do |a| + a[:name] == attr.to_s + end + end + 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) + + # Get unique combinations of record_type and name to validate translatable attributes + ActionText::RichText + .distinct + .pluck(:record_type, :name) + .each do |record_type, attribute_name| + next unless record_type.present? && attribute_name.present? + + begin + model_class = record_type.constantize + load_subclasses(model_class) + + # Check if this specific attribute is translatable in the model or its descendants + if has_translatable_rich_text_attribute?(model_class, attribute_name) + model_types.add(record_type) + else + Rails.logger.debug "Skipping #{record_type}: attribute '#{attribute_name}' not found in translatable rich text attributes" + end + rescue StandardError => e + Rails.logger.warn "Could not check rich text translatability for #{record_type}: #{e.message}" + end + end + end + + def collect_file_translation_models(model_types) + return unless defined?(ActiveStorage::Attachment) && + ActiveStorage::Attachment.column_names.include?('locale') + + # Get unique combinations of record_type and name to validate translatable attachments + ActiveStorage::Attachment + .where.not(locale: [nil, '']) # Only include records with actual locale values + .distinct + .pluck(:record_type, :name) + .each do |record_type, attachment_name| + next unless record_type.present? && attachment_name.present? + + begin + model_class = record_type.constantize + load_subclasses(model_class) + + # Check if this specific attachment is translatable in the model or its descendants + if has_translatable_attachment?(model_class, attachment_name) + model_types.add(record_type) + else + Rails.logger.debug "Skipping #{record_type}: attachment '#{attachment_name}' not found in translatable attachments" + end + rescue StandardError => e + Rails.logger.warn "Could not check file translatability for #{record_type}: #{e.message}" + end + end + 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 = {} + + 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.count + rescue StandardError + 0 + end + + 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 + } + end + end + + 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 + + # Statistical calculation methods for overview + def calculate_locale_stats + stats = {} + + 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 + + def calculate_unique_translated_records + unique_records = Set.new + + # Collect unique (model_type, record_id) pairs from string translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::StringTranslation) + Mobility::Backends::ActiveRecord::KeyValue::StringTranslation + .distinct + .pluck(:translatable_type, :translatable_id) + .each { |type, id| unique_records.add([type, id]) } + end + + # Collect from text translations + if defined?(Mobility::Backends::ActiveRecord::KeyValue::TextTranslation) + Mobility::Backends::ActiveRecord::KeyValue::TextTranslation + .distinct + .pluck(:translatable_type, :translatable_id) + .each { |type, id| unique_records.add([type, id]) } + end + + # Collect from rich text translations + if defined?(ActionText::RichText) + ActionText::RichText + .distinct + .pluck(:record_type, :record_id) + .each { |type, id| unique_records.add([type, id]) } + end + + # Collect from file translations + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + ActiveStorage::Attachment + .where.not(locale: [nil, '']) + .distinct + .pluck(:record_type, :record_id) + .each { |type, id| unique_records.add([type, id]) } + end + + 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 - # Perform the translation using TranslationBot - translated_content = translation_bot.translate(content, target_locale:, - source_locale:, initiator:) + def calculate_model_instance_stats_optimized + stats = {} - # Return the translated content as JSON - render json: { translation: translated_content } + # 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 = {} + + @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 overall coverage percentage as average of attribute coverages + # This is more accurate than just counting instances with ANY translation + coverage_percentage = if attribute_coverage&.any? + # Calculate average coverage across all attributes + attribute_percentages = attribute_coverage.values.map do |attr| + attr[:coverage_percentage] || 0.0 + end + (attribute_percentages.sum / attribute_percentages.size).round(1) + elsif total_instances.positive? && translated_instances <= total_instances + # Fallback to instance-based calculation if no attributes + (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 + + # Debug logging for troubleshooting + Rails.logger.debug "Calculating coverage for #{model_name}: #{total_instances} total instances" + Rails.logger.debug "Has mobility_attributes? #{model_class.respond_to?(:mobility_attributes)}" + if model_class.respond_to?(:mobility_attributes) + Rails.logger.debug "Mobility attributes: #{model_class.mobility_attributes.inspect}" + end + + # Collect mobility attributes from the model and its subclasses (for STI) + all_attributes = Set.new + + # Load subclasses to ensure they're available in development + load_subclasses(model_class) + + # Get attributes from the main model + if model_class.respond_to?(:mobility_attributes) + model_class.mobility_attributes.each { |attr| all_attributes.add(attr.to_s) } + end + + # For STI models, also check subclasses for their translatable attributes + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + if subclass.respond_to?(:mobility_attributes) + Rails.logger.debug "STI subclass #{subclass.name} has attributes: #{subclass.mobility_attributes.inspect}" + subclass.mobility_attributes.each { |attr| all_attributes.add(attr.to_s) } + end + end + end + + Rails.logger.debug "All collected attributes for #{model_name}: #{all_attributes.to_a.inspect}" + + # Calculate coverage for each unique attribute + all_attributes.each do |attribute_name| + # 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 + + # 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 + + # Collect from file translations (ActiveStorage attachments with locale) + if defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.column_names.include?('locale') + ActiveStorage::Attachment + .where.not(locale: [nil, '']) + .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 + + # Load subclasses for STI models to ensure they're available in development environment + def load_subclasses(model_class) + return unless model_class.respond_to?(:descendants) + + # In development, Rails lazy-loads classes, so we need to force-load STI subclasses + if Rails.env.development? + # Get the base model's directory path + base_path = Rails.application.root.join('app', 'models') + engine_path = BetterTogether::Engine.root.join('app', 'models') + + # Convert class name to file path pattern + model_path = model_class.name.underscore + + # Look for subclass files in both app and engine models + [base_path, engine_path].each do |path| + # Check for files in the same directory as the base model + model_dir = File.dirname(model_path) + pattern = path.join("#{model_dir}/*.rb") + + Dir.glob(pattern).each do |file| + # Extract class name from file path and try to constantize it + relative_path = Pathname.new(file).relative_path_from(path).to_s + class_name = relative_path.gsub('.rb', '').camelize + + begin + # Only try to load if it's not the same as the base class + next if class_name == model_class.name + + loaded_class = class_name.constantize + + # Check if it's actually a subclass of our model + if loaded_class.ancestors.include?(model_class) && loaded_class != model_class + Rails.logger.debug "Successfully loaded subclass: #{class_name}" + end + rescue NameError, LoadError => e + Rails.logger.debug "Could not load potential subclass #{class_name}: #{e.message}" + end + end + end + end + rescue StandardError => e + Rails.logger.warn "Error loading subclasses for #{model_class.name}: #{e.message}" + end + + # Check if a model class has any translatable attributes (including STI descendants) + def has_translatable_attributes?(model_class) + # Check mobility attributes on the model itself + return true if model_class.respond_to?(:mobility_attributes) && model_class.mobility_attributes.any? + + # Check translatable attachments on the model itself + if model_class.respond_to?(:mobility_translated_attachments) && model_class.mobility_translated_attachments&.any? + return true + end + + # For STI models, check descendants + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + return true if subclass.respond_to?(:mobility_attributes) && subclass.mobility_attributes.any? + if subclass.respond_to?(:mobility_translated_attachments) && subclass.mobility_translated_attachments&.any? + return true + end + end + end + + false + end + + # Check if a model has a specific translatable rich text attribute + def has_translatable_rich_text_attribute?(model_class, attribute_name) + # Check if the model has this attribute configured for Action Text translation + if model_class.respond_to?(:mobility_attributes) + mobility_configs = model_class.mobility.attributes_hash + return true if mobility_configs[attribute_name.to_sym]&.dig(:backend) == :action_text + end + + # Check STI descendants + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + next unless subclass.respond_to?(:mobility_attributes) + + mobility_configs = subclass.mobility.attributes_hash + return true if mobility_configs[attribute_name.to_sym]&.dig(:backend) == :action_text + end + end + + false + end + + # Check if a model has a specific translatable attachment + def has_translatable_attachment?(model_class, attachment_name) + # Check if the model has this attachment configured as translatable + if model_class.respond_to?(:mobility_translated_attachments) + return model_class.mobility_translated_attachments&.key?(attachment_name.to_sym) + end + + # Check STI descendants + if model_class.respond_to?(:descendants) && model_class.descendants.any? + model_class.descendants.each do |subclass| + if subclass.respond_to?(:mobility_translated_attachments) && subclass.mobility_translated_attachments&.key?(attachment_name.to_sym) + return true + end + end + end + + 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 - render json: { error: "Translation failed: #{e.message}" }, status: :unprocessable_content + 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/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/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/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/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..f740e2b5e 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 :name, type: :string 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 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 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..378e95c82 --- /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', 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/_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/_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 new file mode 100644 index 000000000..f99865177 --- /dev/null +++ b/app/views/better_together/translations/_overview.html.erb @@ -0,0 +1,446 @@ + + +
+
+
+
+
+ + <%= t('.summary_statistics') %> +
+
+
+
+
+
+

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

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

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

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

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

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

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

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

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

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

+ + <%= 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.each do |attribute, count| %> +
+ + <%= attribute %> + + + <%= number_with_delimiter(count) %> <%= t('.records') %> + +
+ <% end %> + <% else %> +

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

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

+ + <%= 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 + +
+
+
+ <%= 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 %>% + + +
+
+
+ +
+
+
+
<%= 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 %> + + + +
+
+
+ <% end %> +
+
+ + + + <% else %> +
+ + <%= t('.no_model_instance_data') %> +
+ <% end %> +
+
+ + + 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/_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/_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 new file mode 100644 index 000000000..61eac3702 --- /dev/null +++ b/app/views/better_together/translations/index.html.erb @@ -0,0 +1,147 @@ +<% content_for :page_title, t('.title') %> + +
+
+
+

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

+ + + + + +
+ +
+ <%= render 'overview_lightweight' %> +
+ + +
+ <%= turbo_frame_tag :translations_by_locale, loading: :lazy, src: better_together.by_locale_translations_path do %> + <%= 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 %> + <%= 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 %> + <%= 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 %> + <%= render 'loading_placeholder', message: t('.loading_by_attribute') %> + <% end %> +
+
+
+
+
+ +<%= 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/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 @@