From b6646e778d43a9b6ba768a8799830095cfcff635 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 20 Jul 2017 08:53:57 +0200 Subject: [PATCH 01/16] Track the locale in Sentry so we know which ones are failing --- lib/gitlab/sentry.rb | 2 ++ spec/lib/gitlab/sentry_spec.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 spec/lib/gitlab/sentry_spec.rb diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb index 2442c2ded3b..d7e73f30abf 100644 --- a/lib/gitlab/sentry.rb +++ b/lib/gitlab/sentry.rb @@ -7,6 +7,8 @@ module Gitlab def self.context(current_user = nil) return unless self.enabled? + Raven.tags_context(locale: I18n.locale) + if current_user Raven.user_context( id: current_user.id, diff --git a/spec/lib/gitlab/sentry_spec.rb b/spec/lib/gitlab/sentry_spec.rb new file mode 100644 index 00000000000..8c211d1c63f --- /dev/null +++ b/spec/lib/gitlab/sentry_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Sentry do + describe '.context' do + it 'adds the locale to the tags' do + expect(described_class).to receive(:enabled?).and_return(true) + + described_class.context(nil) + + expect(Raven.tags_context[:locale]).to eq(I18n.locale.to_s) + end + end +end From 1eb30cfb758d9fa576f1164fe7c5f520867ce378 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 20 Jul 2017 08:54:27 +0200 Subject: [PATCH 02/16] Ignore fuzzy translations --- config/initializers/fast_gettext.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index eb589ecdb52..fd0167aa476 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,4 +1,7 @@ -FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po +FastGettext.add_text_domain 'gitlab', + path: File.join(Rails.root, 'locale'), + type: :po, + ignore_fuzzy: true FastGettext.default_text_domain = 'gitlab' FastGettext.default_available_locales = Gitlab::I18n.available_locales FastGettext.default_locale = :en From bde39322f1b0a24b03c949abbf34b21859f9a5c0 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 20 Jul 2017 17:32:17 +0200 Subject: [PATCH 03/16] Add a linter for PO files --- Gemfile | 2 + Gemfile.lock | 2 + lib/gitlab/po_linter.rb | 168 +++++ lib/gitlab/utils.rb | 4 + lib/tasks/gettext.rake | 40 + scripts/static-analysis | 3 +- spec/fixtures/fuzzy.po | 27 + spec/fixtures/invalid.po | 25 + spec/fixtures/newlines.po | 32 + spec/fixtures/valid.po | 1136 +++++++++++++++++++++++++++++ spec/lib/gitlab/po_linter_spec.rb | 245 +++++++ spec/lib/gitlab/utils_spec.rb | 8 +- 12 files changed, 1690 insertions(+), 2 deletions(-) create mode 100644 lib/gitlab/po_linter.rb create mode 100644 spec/fixtures/fuzzy.po create mode 100644 spec/fixtures/invalid.po create mode 100644 spec/fixtures/newlines.po create mode 100644 spec/fixtures/valid.po create mode 100644 spec/lib/gitlab/po_linter_spec.rb diff --git a/Gemfile b/Gemfile index a05747e9ef5..61c941ae449 100644 --- a/Gemfile +++ b/Gemfile @@ -349,6 +349,8 @@ group :development, :test do gem 'activerecord_sane_schema_dumper', '0.2' gem 'stackprof', '~> 0.2.10', require: false + + gem 'simple_po_parser', '~> 1.1.2', require: false end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 8634a9e8822..5974ee8906c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -833,6 +833,7 @@ GEM faraday (~> 0.9) jwt (~> 1.5) multi_json (~> 1.10) + simple_po_parser (1.1.2) simplecov (0.14.1) docile (~> 1.1.0) json (>= 1.8, < 3) @@ -1145,6 +1146,7 @@ DEPENDENCIES sidekiq (~> 5.0) sidekiq-cron (~> 0.6.0) sidekiq-limit_fetch (~> 3.4) + simple_po_parser (~> 1.1.2) simplecov (~> 0.14.0) slack-notifier (~> 1.5.1) spinach-rails (~> 0.2.1) diff --git a/lib/gitlab/po_linter.rb b/lib/gitlab/po_linter.rb new file mode 100644 index 00000000000..54594949711 --- /dev/null +++ b/lib/gitlab/po_linter.rb @@ -0,0 +1,168 @@ +require 'simple_po_parser' + +module Gitlab + class PoLinter + attr_reader :po_path, :entries, :locale + + VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze + + def initialize(po_path, locale = I18n.locale.to_s) + @po_path = po_path + @locale = locale + end + + def errors + @errors ||= validate_po + end + + def validate_po + if parse_error = parse_po + return 'PO-syntax errors' => [parse_error] + end + + validate_entries + end + + def parse_po + @entries = SimplePoParser.parse(po_path) + nil + rescue SimplePoParser::ParserError => e + @entries = [] + e.message + end + + def validate_entries + errors = {} + + entries.each do |entry| + # Skip validation of metadata + next if entry[:msgid].empty? + + errors_for_entry = validate_entry(entry) + errors[join_message(entry[:msgid])] = errors_for_entry if errors_for_entry.any? + end + + errors + end + + def validate_entry(entry) + errors = [] + + validate_flags(errors, entry) + validate_variables(errors, entry) + validate_newlines(errors, entry) + + errors + end + + def validate_newlines(errors, entry) + message_id = join_message(entry[:msgid]) + + if entry[:msgid].is_a?(Array) + errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." + end + end + + def validate_variables(errors, entry) + if entry[:msgid_plural].present? + validate_variables_in_message(errors, entry[:msgid], entry['msgstr[0]']) + validate_variables_in_message(errors, entry[:msgid_plural], entry['msgstr[1]']) + else + validate_variables_in_message(errors, entry[:msgid], entry[:msgstr]) + end + end + + def validate_variables_in_message(errors, message_id, message_translation) + message_id = join_message(message_id) + required_variables = message_id.scan(VARIABLE_REGEX) + + validate_unnamed_variables(errors, required_variables) + validate_translation(errors, message_id, required_variables) + + message_translation = join_message(message_translation) + unless message_translation.empty? + validate_variable_usage(errors, message_translation, required_variables) + end + end + + def validate_translation(errors, message_id, used_variables) + variables = fill_in_variables(used_variables) + + begin + Gitlab::I18n.with_locale(locale) do + translated = if message_id.include?('|') + FastGettext::Translation.s_(message_id) + else + FastGettext::Translation._(message_id) + end + + translated % variables + end + + # `sprintf` could raise an `ArgumentError` when invalid passing something + # other than a Hash when using named variables + # + # `sprintf` could raise `TypeError` when passing a wrong type when using + # unnamed variables + # + # FastGettext::Translation could raise `RuntimeError` (raised as a string), + # or as subclassess `NoTextDomainConfigured` & `InvalidFormat` + # + # `FastGettext::Translation` could raise `ArgumentError` as subclassess + # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter` + rescue ArgumentError, TypeError, RuntimeError => e + errors << "Failure translating to #{locale} with #{variables}: #{e.message}" + end + end + + def fill_in_variables(variables) + if variables.empty? + [] + elsif variables.any? { |variable| unnamed_variable?(variable) } + variables.map do |variable| + variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string + end + else + variables.inject({}) do |hash, variable| + variable_name = variable[/\w+/] + hash[variable_name] = Gitlab::Utils.random_string + hash + end + end + end + + def validate_unnamed_variables(errors, variables) + if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) } + errors << 'is combining multiple unnamed variables' + end + end + + def validate_variable_usage(errors, translation, required_variables) + found_variables = translation.scan(VARIABLE_REGEX) + + missing_variables = required_variables - found_variables + if missing_variables.any? + errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]" + end + + unknown_variables = found_variables - required_variables + if unknown_variables.any? + errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]" + end + end + + def unnamed_variable?(variable_name) + !variable_name.start_with?('%{') + end + + def validate_flags(errors, entry) + if flag = entry[:flag] + errors << "is marked #{flag}" + end + end + + def join_message(message) + Array(message).join + end + end +end diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 9670c93759e..abb3d3a02c3 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -42,5 +42,9 @@ module Gitlab 'No' end end + + def random_string + Random.rand(Float::MAX.to_i).to_s(36) + end end end diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index b48e4dce445..b75da6bf2fc 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -19,4 +19,44 @@ namespace :gettext do Rake::Task['gettext:pack'].invoke Rake::Task['gettext:po_to_json'].invoke end + + desc 'Lint all po files in `locale/' + task lint: :environment do + FastGettext.silence_errors + files = Dir.glob(Rails.root.join('locale/*/gitlab.po')) + + linters = files.map do |file| + locale = File.basename(File.dirname(file)) + + Gitlab::PoLinter.new(file, locale) + end + + pot_file = Rails.root.join('locale/gitlab.pot') + linters.unshift(Gitlab::PoLinter.new(pot_file)) + + failed_linters = linters.select { |linter| linter.errors.any? } + + if failed_linters.empty? + puts 'All PO files are valid.' + else + failed_linters.each do |linter| + report_errors_for_file(linter.po_path, linter.errors) + end + + raise "Not all PO-files are valid: #{failed_linters.map(&:po_path).to_sentence}" + end + end + + def report_errors_for_file(file, errors_for_file) + puts "Errors in `#{file}`:" + + errors_for_file.each do |message_id, errors| + puts " #{message_id}" + errors.each do |error| + spaces = ' ' * 4 + error = error.lines.join("#{spaces}") + puts "#{spaces}#{error}" + end + end + end end diff --git a/scripts/static-analysis b/scripts/static-analysis index 52529e64b30..295b6f132c1 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -12,7 +12,8 @@ tasks = [ %w[bundle exec license_finder], %w[yarn run eslint], %w[bundle exec rubocop --require rubocop-rspec], - %w[scripts/lint-conflicts.sh] + %w[scripts/lint-conflicts.sh], + %w[bundle exec rake gettext:lint] ] failed_tasks = tasks.reduce({}) do |failures, task| diff --git a/spec/fixtures/fuzzy.po b/spec/fixtures/fuzzy.po new file mode 100644 index 00000000000..99b7d12b91a --- /dev/null +++ b/spec/fixtures/fuzzy.po @@ -0,0 +1,27 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" + +#, fuzzy +msgid "PipelineSchedules|Remove variable row" +msgstr "Схема" diff --git a/spec/fixtures/invalid.po b/spec/fixtures/invalid.po new file mode 100644 index 00000000000..039a56e9fc0 --- /dev/null +++ b/spec/fixtures/invalid.po @@ -0,0 +1,25 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" +msgstr[1] "%d cambios" + +But this doesn't even look like an PO-entry \ No newline at end of file diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po new file mode 100644 index 00000000000..515d0b3ba99 --- /dev/null +++ b/spec/fixtures/newlines.po @@ -0,0 +1,32 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" + +msgid "" +"You are going to remove %{group_name}.\n" +"Removed groups CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" +"Va a eliminar %{group_name}.\n" +"¡El grupo eliminado NO puede ser restaurado!\n" +"¿Estás TOTALMENTE seguro?" diff --git a/spec/fixtures/valid.po b/spec/fixtures/valid.po new file mode 100644 index 00000000000..e43fd5fea15 --- /dev/null +++ b/spec/fixtures/valid.po @@ -0,0 +1,1136 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" +msgstr[1] "%d cambios" + +msgid "%s additional commit has been omitted to prevent performance issues." +msgid_plural "%s additional commits have been omitted to prevent performance issues." +msgstr[0] "%s cambio adicional ha sido omitido para evitar problemas de rendimiento." +msgstr[1] "%s cambios adicionales han sido omitidos para evitar problemas de rendimiento." + +msgid "%{commit_author_link} committed %{commit_timeago}" +msgstr "%{commit_author_link} cambió %{commit_timeago}" + +msgid "1 pipeline" +msgid_plural "%d pipelines" +msgstr[0] "1 pipeline" +msgstr[1] "%d pipelines" + +msgid "A collection of graphs regarding Continuous Integration" +msgstr "Una colección de gráficos sobre Integración Continua" + +msgid "About auto deploy" +msgstr "Acerca del auto despliegue" + +msgid "Active" +msgstr "Activo" + +msgid "Activity" +msgstr "Actividad" + +msgid "Add Changelog" +msgstr "Agregar Changelog" + +msgid "Add Contribution guide" +msgstr "Agregar guía de contribución" + +msgid "Add License" +msgstr "Agregar Licencia" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH." + +msgid "Add new directory" +msgstr "Agregar nuevo directorio" + +msgid "Archived project! Repository is read-only" +msgstr "¡Proyecto archivado! El repositorio es de solo lectura" + +msgid "Are you sure you want to delete this pipeline schedule?" +msgstr "¿Estás seguro que deseas eliminar esta programación del pipeline?" + +msgid "Attach a file by drag & drop or %{upload_link}" +msgstr "Adjunte un archivo arrastrando & soltando o %{upload_link}" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "Rama" +msgstr[1] "Ramas" + +msgid "Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "La rama %{branch_name} fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}" + +msgid "BranchSwitcherPlaceholder|Search branches" +msgstr "Buscar ramas" + +msgid "BranchSwitcherTitle|Switch branch" +msgstr "Cambiar rama" + +msgid "Branches" +msgstr "Ramas" + +msgid "Browse Directory" +msgstr "Examinar directorio" + +msgid "Browse File" +msgstr "Examinar archivo" + +msgid "Browse Files" +msgstr "Examinar archivos" + +msgid "Browse files" +msgstr "Examinar archivos" + +msgid "ByAuthor|by" +msgstr "por" + +msgid "CI configuration" +msgstr "Configuración de CI" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "ChangeTypeActionLabel|Pick into branch" +msgstr "Escoger en la rama" + +msgid "ChangeTypeActionLabel|Revert in branch" +msgstr "Revertir en la rama" + +msgid "ChangeTypeAction|Cherry-pick" +msgstr "Cherry-pick" + +msgid "ChangeTypeAction|Revert" +msgstr "Revertir" + +msgid "Changelog" +msgstr "Changelog" + +msgid "Charts" +msgstr "Gráficos" + +msgid "Cherry-pick this commit" +msgstr "Escoger este cambio" + +msgid "Cherry-pick this merge request" +msgstr "Escoger esta solicitud de fusión" + +msgid "CiStatusLabel|canceled" +msgstr "cancelado" + +msgid "CiStatusLabel|created" +msgstr "creado" + +msgid "CiStatusLabel|failed" +msgstr "fallido" + +msgid "CiStatusLabel|manual action" +msgstr "acción manual" + +msgid "CiStatusLabel|passed" +msgstr "pasó" + +msgid "CiStatusLabel|passed with warnings" +msgstr "pasó con advertencias" + +msgid "CiStatusLabel|pending" +msgstr "pendiente" + +msgid "CiStatusLabel|skipped" +msgstr "omitido" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "esperando acción manual" + +msgid "CiStatusText|blocked" +msgstr "bloqueado" + +msgid "CiStatusText|canceled" +msgstr "cancelado" + +msgid "CiStatusText|created" +msgstr "creado" + +msgid "CiStatusText|failed" +msgstr "fallado" + +msgid "CiStatusText|manual" +msgstr "manual" + +msgid "CiStatusText|passed" +msgstr "pasó" + +msgid "CiStatusText|pending" +msgstr "pendiente" + +msgid "CiStatusText|skipped" +msgstr "omitido" + +msgid "CiStatus|running" +msgstr "en ejecución" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "Cambio" +msgstr[1] "Cambios" + +msgid "Commit duration in minutes for last 30 commits" +msgstr "Duración de los cambios en minutos para los últimos 30" + +msgid "Commit message" +msgstr "Mensaje del cambio" + +msgid "CommitBoxTitle|Commit" +msgstr "Cambio" + +msgid "CommitMessage|Add %{file_name}" +msgstr "Agregar %{file_name}" + +msgid "Commits" +msgstr "Cambios" + +msgid "Commits feed" +msgstr "Feed de cambios" + +msgid "Commits|History" +msgstr "Historial" + +msgid "Committed by" +msgstr "Enviado por" + +msgid "Compare" +msgstr "Comparar" + +msgid "Contribution guide" +msgstr "Guía de contribución" + +msgid "Contributors" +msgstr "Contribuidores" + +msgid "Copy URL to clipboard" +msgstr "Copiar URL al portapapeles" + +msgid "Copy commit SHA to clipboard" +msgstr "Copiar SHA del cambio al portapapeles" + +msgid "Create New Directory" +msgstr "Crear Nuevo Directorio" + +msgid "Create a personal access token on your account to pull or push via %{protocol}." +msgstr "Crear un token de acceso personal en tu cuenta para actualizar o enviar a través de %{protocol}." + +msgid "Create directory" +msgstr "Crear directorio" + +msgid "Create empty bare repository" +msgstr "Crear repositorio vacío" + +msgid "Create merge request" +msgstr "Crear solicitud de fusión" + +msgid "Create new..." +msgstr "Crear nuevo..." + +msgid "CreateNewFork|Fork" +msgstr "Bifurcar" + +msgid "CreateTag|Tag" +msgstr "Etiqueta" + +msgid "CreateTokenToCloneLink|create a personal access token" +msgstr "crear un token de acceso personal" + +msgid "Cron Timezone" +msgstr "Zona horaria del Cron" + +msgid "Cron syntax" +msgstr "Sintaxis de Cron" + +msgid "Custom notification events" +msgstr "Eventos de notificaciones personalizadas" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}." + +msgid "Cycle Analytics" +msgstr "Cycle Analytics" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." + +msgid "CycleAnalyticsStage|Code" +msgstr "Código" + +msgid "CycleAnalyticsStage|Issue" +msgstr "Incidencia" + +msgid "CycleAnalyticsStage|Plan" +msgstr "Planificación" + +msgid "CycleAnalyticsStage|Production" +msgstr "Producción" + +msgid "CycleAnalyticsStage|Review" +msgstr "Revisión" + +msgid "CycleAnalyticsStage|Staging" +msgstr "Puesta en escena" + +msgid "CycleAnalyticsStage|Test" +msgstr "Pruebas" + +msgid "Define a custom pattern with cron syntax" +msgstr "Definir un patrón personalizado con la sintaxis de cron" + +msgid "Delete" +msgstr "Eliminar" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "Despliegue" +msgstr[1] "Despliegues" + +msgid "Description" +msgstr "Descripción" + +msgid "Directory name" +msgstr "Nombre del directorio" + +msgid "Don't show again" +msgstr "No mostrar de nuevo" + +msgid "Download" +msgstr "Descargar" + +msgid "Download tar" +msgstr "Descargar tar" + +msgid "Download tar.bz2" +msgstr "Descargar tar.bz2" + +msgid "Download tar.gz" +msgstr "Descargar tar.gz" + +msgid "Download zip" +msgstr "Descargar zip" + +msgid "DownloadArtifacts|Download" +msgstr "Descargar" + +msgid "DownloadCommit|Email Patches" +msgstr "Parches por correo electrónico" + +msgid "DownloadCommit|Plain Diff" +msgstr "Diferencias en texto plano" + +msgid "DownloadSource|Download" +msgstr "Descargar" + +msgid "Edit" +msgstr "Editar" + +msgid "Edit Pipeline Schedule %{id}" +msgstr "Editar Programación del Pipeline %{id}" + +msgid "Every day (at 4:00am)" +msgstr "Todos los días (a las 4:00 am)" + +msgid "Every month (on the 1st at 4:00am)" +msgstr "Todos los meses (el día 1 a las 4:00 am)" + +msgid "Every week (Sundays at 4:00am)" +msgstr "Todas las semanas (domingos a las 4:00 am)" + +msgid "Failed to change the owner" +msgstr "Error al cambiar el propietario" + +msgid "Failed to remove the pipeline schedule" +msgstr "Error al eliminar la programación del pipeline" + +msgid "Files" +msgstr "Archivos" + +msgid "Filter by commit message" +msgstr "Filtrar por mensaje del cambio" + +msgid "Find by path" +msgstr "Buscar por ruta" + +msgid "Find file" +msgstr "Buscar archivo" + +msgid "FirstPushedBy|First" +msgstr "Primer" + +msgid "FirstPushedBy|pushed by" +msgstr "enviado por" + +msgid "Fork" +msgid_plural "Forks" +msgstr[0] "Bifurcación" +msgstr[1] "Bifurcaciones" + +msgid "ForkedFromProjectPath|Forked from" +msgstr "Bifurcado de" + +msgid "From issue creation until deploy to production" +msgstr "Desde la creación de la incidencia hasta el despliegue a producción" + +msgid "From merge request merge until deploy to production" +msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" + +msgid "Go to your fork" +msgstr "Ir a tu bifurcación" + +msgid "GoToYourFork|Fork" +msgstr "Bifurcación" + +msgid "Home" +msgstr "Inicio" + +msgid "Housekeeping successfully started" +msgstr "Servicio de limpieza iniciado con éxito" + +msgid "Import repository" +msgstr "Importar repositorio" + +msgid "Interval Pattern" +msgstr "Patrón de intervalo" + +msgid "Introducing Cycle Analytics" +msgstr "Introducción a Cycle Analytics" + +msgid "Jobs for last month" +msgstr "Trabajos del mes pasado" + +msgid "Jobs for last week" +msgstr "Trabajos de la semana pasada" + +msgid "Jobs for last year" +msgstr "Trabajos del año pasado" + +msgid "LFSStatus|Disabled" +msgstr "Deshabilitado" + +msgid "LFSStatus|Enabled" +msgstr "Habilitado" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "Último %d día" +msgstr[1] "Últimos %d días" + +msgid "Last Pipeline" +msgstr "Último Pipeline" + +msgid "Last Update" +msgstr "Última actualización" + +msgid "Last commit" +msgstr "Último cambio" + +msgid "Learn more in the" +msgstr "Más información en la" + +msgid "Learn more in the|pipeline schedules documentation" +msgstr "documentación sobre la programación de pipelines" + +msgid "Leave group" +msgstr "Abandonar grupo" + +msgid "Leave project" +msgstr "Abandonar proyecto" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "Limitado a mostrar máximo %d evento" +msgstr[1] "Limitado a mostrar máximo %d eventos" + +msgid "Median" +msgstr "Mediana" + +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "agregar una clave SSH" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "Nueva incidencia" +msgstr[1] "Nuevas incidencias" + +msgid "New Pipeline Schedule" +msgstr "Nueva Programación del Pipeline" + +msgid "New branch" +msgstr "Nueva rama" + +msgid "New directory" +msgstr "Nuevo directorio" + +msgid "New file" +msgstr "Nuevo archivo" + +msgid "New issue" +msgstr "Nueva incidencia" + +msgid "New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "New schedule" +msgstr "Nueva programación" + +msgid "New snippet" +msgstr "Nuevo fragmento de código" + +msgid "New tag" +msgstr "Nueva etiqueta" + +msgid "No repository" +msgstr "No hay repositorio" + +msgid "No schedules" +msgstr "No hay programaciones" + +msgid "Not available" +msgstr "No disponible" + +msgid "Not enough data" +msgstr "No hay suficientes datos" + +msgid "Notification events" +msgstr "Eventos de notificación" + +msgid "NotificationEvent|Close issue" +msgstr "Cerrar incidencia" + +msgid "NotificationEvent|Close merge request" +msgstr "Cerrar solicitud de fusión" + +msgid "NotificationEvent|Failed pipeline" +msgstr "Pipeline fallido" + +msgid "NotificationEvent|Merge merge request" +msgstr "Integrar solicitud de fusión" + +msgid "NotificationEvent|New issue" +msgstr "Nueva incidencia" + +msgid "NotificationEvent|New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "NotificationEvent|New note" +msgstr "Nueva nota" + +msgid "NotificationEvent|Reassign issue" +msgstr "Reasignar incidencia" + +msgid "NotificationEvent|Reassign merge request" +msgstr "Reasignar solicitud de fusión" + +msgid "NotificationEvent|Reopen issue" +msgstr "Reabrir incidencia" + +msgid "NotificationEvent|Successful pipeline" +msgstr "Pipeline exitoso" + +msgid "NotificationLevel|Custom" +msgstr "Personalizado" + +msgid "NotificationLevel|Disabled" +msgstr "Deshabilitado" + +msgid "NotificationLevel|Global" +msgstr "Global" + +msgid "NotificationLevel|On mention" +msgstr "Cuando me mencionan" + +msgid "NotificationLevel|Participate" +msgstr "Participación" + +msgid "NotificationLevel|Watch" +msgstr "Vigilancia" + +msgid "OfSearchInADropdown|Filter" +msgstr "Filtrar" + +msgid "OpenedNDaysAgo|Opened" +msgstr "Abierto" + +msgid "Options" +msgstr "Opciones" + +msgid "Owner" +msgstr "Propietario" + +msgid "Pipeline" +msgstr "Pipeline" + +msgid "Pipeline Health" +msgstr "Estado del Pipeline" + +msgid "Pipeline Schedule" +msgstr "Programación del Pipeline" + +msgid "Pipeline Schedules" +msgstr "Programaciones de los Pipelines" + +msgid "PipelineCharts|Failed:" +msgstr "Fallidos:" + +msgid "PipelineCharts|Overall statistics" +msgstr "Estadísticas generales" + +msgid "PipelineCharts|Success ratio:" +msgstr "Ratio de éxito" + +msgid "PipelineCharts|Successful:" +msgstr "Exitosos:" + +msgid "PipelineCharts|Total:" +msgstr "Total:" + +msgid "PipelineSchedules|Activated" +msgstr "Activado" + +msgid "PipelineSchedules|Active" +msgstr "Activos" + +msgid "PipelineSchedules|All" +msgstr "Todos" + +msgid "PipelineSchedules|Inactive" +msgstr "Inactivos" + +msgid "PipelineSchedules|Input variable key" +msgstr "Ingrese nombre de clave" + +msgid "PipelineSchedules|Input variable value" +msgstr "Ingrese el valor de la variable" + +msgid "PipelineSchedules|Next Run" +msgstr "Próxima Ejecución" + +msgid "PipelineSchedules|None" +msgstr "Ninguno" + +msgid "PipelineSchedules|Provide a short description for this pipeline" +msgstr "Proporcione una descripción breve para este pipeline" + +msgid "PipelineSchedules|Remove variable row" +msgstr "Eliminar fila de variable" + +msgid "PipelineSchedules|Take ownership" +msgstr "Tomar posesión" + +msgid "PipelineSchedules|Target" +msgstr "Destino" + +msgid "PipelineSchedules|Variables" +msgstr "Variables" + +msgid "PipelineSheduleIntervalPattern|Custom" +msgstr "Personalizado" + +msgid "Pipelines" +msgstr "Pipelines" + +msgid "Pipelines charts" +msgstr "Gráficos de los pipelines" + +msgid "Pipeline|all" +msgstr "todos" + +msgid "Pipeline|success" +msgstr "exitósos" + +msgid "Pipeline|with stage" +msgstr "con etapa" + +msgid "Pipeline|with stages" +msgstr "con etapas" + +msgid "Project '%{project_name}' queued for deletion." +msgstr "Proyecto ‘%{project_name}’ en cola para eliminación." + +msgid "Project '%{project_name}' was successfully created." +msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente." + +msgid "Project '%{project_name}' was successfully updated." +msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente." + +msgid "Project '%{project_name}' will be deleted." +msgstr "Proyecto ‘%{project_name}’ será eliminado." + +msgid "Project access must be granted explicitly to each user." +msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario." + +msgid "Project export could not be deleted." +msgstr "No se pudo eliminar la exportación del proyecto." + +msgid "Project export has been deleted." +msgstr "La exportación del proyecto ha sido eliminada." + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto." + +msgid "Project export started. A download link will be sent by email." +msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico." + +msgid "Project home" +msgstr "Inicio del proyecto" + +msgid "ProjectFeature|Disabled" +msgstr "Deshabilitada" + +msgid "ProjectFeature|Everyone with access" +msgstr "Todos con acceso" + +msgid "ProjectFeature|Only team members" +msgstr "Solo miembros del equipo" + +msgid "ProjectFileTree|Name" +msgstr "Nombre" + +msgid "ProjectLastActivity|Never" +msgstr "Nunca" + +msgid "ProjectLifecycle|Stage" +msgstr "Etapa" + +msgid "ProjectNetworkGraph|Graph" +msgstr "Historial gráfico" + +msgid "Read more" +msgstr "Leer más" + +msgid "Readme" +msgstr "Léeme" + +msgid "RefSwitcher|Branches" +msgstr "Ramas" + +msgid "RefSwitcher|Tags" +msgstr "Etiquetas" + +msgid "Related Commits" +msgstr "Cambios Relacionados" + +msgid "Related Deployed Jobs" +msgstr "Trabajos Desplegados Relacionados" + +msgid "Related Issues" +msgstr "Incidencias Relacionadas" + +msgid "Related Jobs" +msgstr "Trabajos Relacionados" + +msgid "Related Merge Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Related Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Remind later" +msgstr "Recordar después" + +msgid "Remove project" +msgstr "Eliminar proyecto" + +msgid "Request Access" +msgstr "Solicitar acceso" + +msgid "Revert this commit" +msgstr "Revertir este cambio" + +msgid "Revert this merge request" +msgstr "Revertir esta solicitud de fusión" + +msgid "Save pipeline schedule" +msgstr "Guardar programación del pipeline" + +msgid "Schedule a new pipeline" +msgstr "Programar un nuevo pipeline" + +msgid "Scheduling Pipelines" +msgstr "Programación de Pipelines" + +msgid "Search branches and tags" +msgstr "Buscar ramas y etiquetas" + +msgid "Select Archive Format" +msgstr "Seleccionar formato de archivo" + +msgid "Select a timezone" +msgstr "Selecciona una zona horaria" + +msgid "Select target branch" +msgstr "Selecciona una rama de destino" + +msgid "Set a password on your account to pull or push via %{protocol}." +msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de %{protocol}." + +msgid "Set up CI" +msgstr "Configurar CI" + +msgid "Set up Koding" +msgstr "Configurar Koding" + +msgid "Set up auto deploy" +msgstr "Configurar auto despliegue" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "establecer una contraseña" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "Mostrando %d evento" +msgstr[1] "Mostrando %d eventos" + +msgid "Source code" +msgstr "Código fuente" + +msgid "StarProject|Star" +msgstr "Destacar" + +msgid "Start a %{new_merge_request} with these changes" +msgstr "Iniciar una %{new_merge_request} con estos cambios" + +msgid "Switch branch/tag" +msgstr "Cambiar rama/etiqueta" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "Etiqueta" +msgstr[1] "Etiquetas" + +msgid "Tags" +msgstr "Etiquetas" + +msgid "Target Branch" +msgstr "Rama de destino" + +msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." +msgstr "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión." + +msgid "The collection of events added to the data gathered for that stage." +msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." + +msgid "The fork relationship has been removed." +msgstr "La relación con la bifurcación se ha eliminado." + +msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." +msgstr "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." + +msgid "The phase of the development lifecycle." +msgstr "La etapa del ciclo de vida de desarrollo." + +msgid "The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user." +msgstr "La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado." + +msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." +msgstr "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio." + +msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." +msgstr "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." + +msgid "The project can be accessed by any logged in user." +msgstr "El proyecto puede ser accedido por cualquier usuario conectado." + +msgid "The project can be accessed without any authentication." +msgstr "El proyecto puede accederse sin ninguna autenticación." + +msgid "The repository for this project does not exist." +msgstr "El repositorio para este proyecto no existe." + +msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." +msgstr "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." + +msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." +msgstr "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." + +msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." +msgstr "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." + +msgid "The time taken by each data entry gathered by that stage." +msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." + +msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgstr "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." + +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente." + +msgid "Time before an issue gets scheduled" +msgstr "Tiempo antes de que una incidencia sea programada" + +msgid "Time before an issue starts implementation" +msgstr "Tiempo antes de que empieze la implementación de una incidencia" + +msgid "Time between merge request creation and merge/close" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta" + +msgid "Time until first merge request" +msgstr "Tiempo hasta la primera solicitud de fusión" + +msgid "Timeago|%s days ago" +msgstr "hace %s días" + +msgid "Timeago|%s days remaining" +msgstr "%s días restantes" + +msgid "Timeago|%s hours remaining" +msgstr "%s horas restantes" + +msgid "Timeago|%s minutes ago" +msgstr "hace %s minutos" + +msgid "Timeago|%s minutes remaining" +msgstr "%s minutos restantes" + +msgid "Timeago|%s months ago" +msgstr "hace %s meses" + +msgid "Timeago|%s months remaining" +msgstr "%s meses restantes" + +msgid "Timeago|%s seconds remaining" +msgstr "%s segundos restantes" + +msgid "Timeago|%s weeks ago" +msgstr "hace %s semanas" + +msgid "Timeago|%s weeks remaining" +msgstr "%s semanas restantes" + +msgid "Timeago|%s years ago" +msgstr "hace %s años" + +msgid "Timeago|%s years remaining" +msgstr "%s años restantes" + +msgid "Timeago|1 day remaining" +msgstr "1 día restante" + +msgid "Timeago|1 hour remaining" +msgstr "1 hora restante" + +msgid "Timeago|1 minute remaining" +msgstr "1 minuto restante" + +msgid "Timeago|1 month remaining" +msgstr "1 mes restante" + +msgid "Timeago|1 week remaining" +msgstr "1 semana restante" + +msgid "Timeago|1 year remaining" +msgstr "1 año restante" + +msgid "Timeago|Past due" +msgstr "Atrasado" + +msgid "Timeago|a day ago" +msgstr "hace un día" + +msgid "Timeago|a month ago" +msgstr "hace un mes" + +msgid "Timeago|a week ago" +msgstr "hace una semana" + +msgid "Timeago|a while" +msgstr "hace un momento" + +msgid "Timeago|a year ago" +msgstr "hace un año" + +msgid "Timeago|about %s hours ago" +msgstr "hace alrededor de %s horas" + +msgid "Timeago|about a minute ago" +msgstr "hace alrededor de 1 minuto" + +msgid "Timeago|about an hour ago" +msgstr "hace alrededor de 1 hora" + +msgid "Timeago|in %s days" +msgstr "en %s días" + +msgid "Timeago|in %s hours" +msgstr "en %s horas" + +msgid "Timeago|in %s minutes" +msgstr "en %s minutos" + +msgid "Timeago|in %s months" +msgstr "en %s meses" + +msgid "Timeago|in %s seconds" +msgstr "en %s segundos" + +msgid "Timeago|in %s weeks" +msgstr "en %s semanas" + +msgid "Timeago|in %s years" +msgstr "en %s años" + +msgid "Timeago|in 1 day" +msgstr "en 1 día" + +msgid "Timeago|in 1 hour" +msgstr "en 1 hora" + +msgid "Timeago|in 1 minute" +msgstr "en 1 minuto" + +msgid "Timeago|in 1 month" +msgstr "en 1 mes" + +msgid "Timeago|in 1 week" +msgstr "en 1 semana" + +msgid "Timeago|in 1 year" +msgstr "en 1 año" + +msgid "Timeago|less than a minute ago" +msgstr "hace menos de 1 minuto" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "hr" +msgstr[1] "hrs" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "min" +msgstr[1] "mins" + +msgid "Time|s" +msgstr "s" + +msgid "Total Time" +msgstr "Tiempo Total" + +msgid "Total test time for all commits/merges" +msgstr "Tiempo total de pruebas para todos los cambios o integraciones" + +msgid "Unstar" +msgstr "No Destacar" + +msgid "Upload New File" +msgstr "Subir nuevo archivo" + +msgid "Upload file" +msgstr "Subir archivo" + +msgid "UploadLink|click to upload" +msgstr "Hacer clic para subir" + +msgid "Use your global notification setting" +msgstr "Utiliza tu configuración de notificación global" + +msgid "View open merge request" +msgstr "Ver solicitud de fusión abierta" + +msgid "VisibilityLevel|Internal" +msgstr "Interno" + +msgid "VisibilityLevel|Private" +msgstr "Privado" + +msgid "VisibilityLevel|Public" +msgstr "Público" + +msgid "VisibilityLevel|Unknown" +msgstr "Desconocido" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." + +msgid "We don't have enough data to show this stage." +msgstr "No hay suficientes datos para mostrar en esta etapa." + +msgid "Withdraw Access Request" +msgstr "Retirar Solicitud de Acceso" + +msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" +msgstr "Va a eliminar %{group_name}. ¡El grupo eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?" + +msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" +msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?" + +msgid "You can only add files when you are on a branch" +msgstr "Solo puedes agregar archivos cuando estás en una rama" + +msgid "You have reached your project limit" +msgstr "Has alcanzado el límite de tu proyecto" + +msgid "You must sign in to star a project" +msgstr "Debes iniciar sesión para destacar un proyecto" + +msgid "You need permission." +msgstr "Necesitas permisos." + +msgid "You will not get any notifications via email" +msgstr "No recibirás ninguna notificación por correo electrónico" + +msgid "You will only receive notifications for the events you choose" +msgstr "Solo recibirás notificaciones de los eventos que elijas" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "Solo recibirás notificaciones de los temas en los que has participado" + +msgid "You will receive notifications for any activity" +msgstr "Recibirás notificaciones por cualquier actividad" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "Recibirás notificaciones solo para los comentarios en los que se te mencionó" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil" + +msgid "Your name" +msgstr "Tu nombre" + +msgid "day" +msgid_plural "days" +msgstr[0] "día" +msgstr[1] "días" + +msgid "new merge request" +msgstr "nueva solicitud de fusión" + +msgid "notification emails" +msgstr "correos electrónicos de notificación" + +msgid "parent" +msgid_plural "parents" +msgstr[0] "padre" +msgstr[1] "padres" diff --git a/spec/lib/gitlab/po_linter_spec.rb b/spec/lib/gitlab/po_linter_spec.rb new file mode 100644 index 00000000000..b59cefd294e --- /dev/null +++ b/spec/lib/gitlab/po_linter_spec.rb @@ -0,0 +1,245 @@ +require 'spec_helper' + +describe Gitlab::PoLinter do + let(:linter) { described_class.new(po_path) } + let(:po_path) { 'spec/fixtures/valid.po' } + + describe '#errors' do + it 'only calls validation once' do + expect(linter).to receive(:validate_po).once.and_call_original + + 2.times { linter.errors } + end + end + + describe '#validate_po' do + subject(:errors) { linter.validate_po } + + context 'for a fuzzy message' do + let(:po_path) { 'spec/fixtures/fuzzy.po' } + + it 'has an error' do + is_expected.to include('PipelineSchedules|Remove variable row' => ['is marked fuzzy']) + end + end + + context 'for a translations with newlines' do + let(:po_path) { 'spec/fixtures/newlines.po' } + + it 'has an error' do + message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" + expected_message = "<#{message_id}> is defined over multiple lines, this breaks some tooling." + + is_expected.to include(message_id => [expected_message]) + end + end + + context 'with an invalid po' do + let(:po_path) { 'spec/fixtures/invalid.po' } + + it 'returns the error' do + is_expected.to include('PO-syntax errors' => a_kind_of(Array)) + end + + it 'does not validate entries' do + expect(linter).not_to receive(:validate_entries) + + linter.validate_po + end + end + + context 'with a valid po' do + it 'parses the file' do + expect(linter).to receive(:parse_po).and_call_original + + linter.validate_po + end + + it 'validates the entries' do + expect(linter).to receive(:validate_entries).and_call_original + + linter.validate_po + end + + it 'has no errors' do + is_expected.to be_empty + end + end + end + + describe '#parse_po' do + context 'with a valid po' do + it 'fills in the entries' do + linter.parse_po + + expect(linter.entries).not_to be_empty + end + + it 'does not have errors' do + expect(linter.parse_po).to be_nil + end + end + + context 'with an invalid po' do + let(:po_path) { 'spec/fixtures/invalid.po' } + + it 'contains an error' do + expect(linter.parse_po).not_to be_nil + end + + it 'sets the entries to an empty array' do + linter.parse_po + + expect(linter.entries).to eq([]) + end + end + end + + describe '#validate_entries' do + it 'skips entries without a `msgid`' do + allow(linter).to receive(:entries) { [{ msgid: "" }] } + + expect(linter.validate_entries).to be_empty + end + + it 'keeps track of errors for entries' do + fake_invalid_entry = { msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" } + allow(linter).to receive(:entries) { [fake_invalid_entry] } + + expect(linter).to receive(:validate_entry) + .with(fake_invalid_entry) + .and_call_original + + expect(linter.validate_entries).to include("Hello %{world}" => an_instance_of(Array)) + end + end + + describe '#validate_entry' do + it 'validates the flags, variable usage, and newlines' do + fake_entry = double + + expect(linter).to receive(:validate_flags).with([], fake_entry) + expect(linter).to receive(:validate_variables).with([], fake_entry) + expect(linter).to receive(:validate_newlines).with([], fake_entry) + + linter.validate_entry(fake_entry) + end + end + + describe '#validate_variables' do + it 'validates both signular and plural in a pluralized string' do + pluralized_entry = { + msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}', + 'msgstr[1]' => 'Bonjour tous %{world}' + } + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello %{world}', 'Bonjour %{world}') + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello all %{world}', 'Bonjour tous %{world}') + + linter.validate_variables([], pluralized_entry) + end + + it 'validates the message variables' do + entry = { msgid: 'Hello', msgstr: 'Bonjour' } + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello', 'Bonjour') + + linter.validate_variables([], entry) + end + end + + describe '#validate_variables_in_message' do + it 'detects when a variables are used incorrectly' do + errors = [] + + expected_errors = [' is missing: [%{hello}]', + ' is using unknown variables: [%{world}]', + 'is combining multiple unnamed variables'] + + linter.validate_variables_in_message(errors, '%{hello} world %d', 'hello %{world} %d') + + expect(errors).to include(*expected_errors) + end + end + + describe '#validate_translation' do + it 'succeeds with valid variables' do + errors = [] + + linter.validate_translation(errors, 'Hello %{world}', ['%{world}']) + + expect(errors).to be_empty + end + + it 'adds an error message when translating fails' do + errors = [] + + expect(FastGettext::Translation).to receive(:_) { raise 'broken' } + + linter.validate_translation(errors, 'Hello', []) + + expect(errors).to include('Failure translating to en with []: broken') + end + + it 'adds an error message when translating fails when translating with context' do + errors = [] + + expect(FastGettext::Translation).to receive(:s_) { raise 'broken' } + + linter.validate_translation(errors, 'Tests|Hello', []) + + expect(errors).to include('Failure translating to en with []: broken') + end + + + it "adds an error when trying to translate with incorrect variables when using unnamed variables" do + errors = [] + + linter.validate_translation(errors, 'Hello %d', ['%s']) + + expect(errors.first).to start_with("Failure translating to en with") + end + + it "adds an error when trying to translate with named variables when unnamed variables are expected" do + errors = [] + + linter.validate_translation(errors, 'Hello %d', ['%{world}']) + + expect(errors.first).to start_with("Failure translating to en with") + end + + it 'adds an error when translated with incorrect variables using named variables' do + errors = [] + + linter.validate_translation(errors, 'Hello %{thing}', ['%d']) + + expect(errors.first).to start_with("Failure translating to en with") + end + end + + describe '#fill_in_variables' do + it 'builds an array for %d translations' do + result = linter.fill_in_variables(['%d']) + + expect(result).to contain_exactly(a_kind_of(Integer)) + end + + it 'builds an array for %s translations' do + result = linter.fill_in_variables(['%s']) + + expect(result).to contain_exactly(a_kind_of(String)) + end + + it 'builds a hash for named variables' do + result = linter.fill_in_variables(['%{hello}']) + + expect(result).to be_a(Hash) + expect(result).to include('hello' => an_instance_of(String)) + end + end +end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 92787bb262e..3137a72fdc4 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Utils do - delegate :to_boolean, :boolean_to_yes_no, :slugify, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, to: :described_class describe '.slugify' do { @@ -53,4 +53,10 @@ describe Gitlab::Utils do expect(boolean_to_yes_no(false)).to eq('No') end end + + describe '.random_string' do + it 'generates a string' do + expect(random_string).to be_kind_of(String) + end + end end From 3dd7b17a7785e54f97100711b18defce4f39dc52 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 20 Jul 2017 22:30:12 +0200 Subject: [PATCH 04/16] Fix currently invalid po files --- .../unreleased/bvl-validate-po-files.yml | 4 ++ locale/en/gitlab.po | 48 +++++++++++++++++++ locale/gitlab.pot | 1 - locale/ja/gitlab.po | 2 +- locale/ko/gitlab.po | 2 +- locale/zh_CN/gitlab.po | 2 +- locale/zh_HK/gitlab.po | 2 +- locale/zh_TW/gitlab.po | 10 ++-- 8 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 changelogs/unreleased/bvl-validate-po-files.yml diff --git a/changelogs/unreleased/bvl-validate-po-files.yml b/changelogs/unreleased/bvl-validate-po-files.yml new file mode 100644 index 00000000000..f840b2c3973 --- /dev/null +++ b/changelogs/unreleased/bvl-validate-po-files.yml @@ -0,0 +1,4 @@ +--- +title: Validate PO-files in static analysis +merge_request: 13000 +author: diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 0ac591d4927..84232be601e 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -82,6 +82,9 @@ msgstr "" msgid "Add new directory" msgstr "" +msgid "All" +msgstr "" + msgid "Archived project! Repository is read-only" msgstr "" @@ -222,6 +225,9 @@ msgstr "" msgid "CiStatus|running" msgstr "" +msgid "Comments" +msgstr "" + msgid "Commit" msgid_plural "Commits" msgstr[0] "" @@ -394,6 +400,24 @@ msgstr "" msgid "Edit Pipeline Schedule %{id}" msgstr "" +msgid "EventFilterBy|Filter by all" +msgstr "" + +msgid "EventFilterBy|Filter by comments" +msgstr "" + +msgid "EventFilterBy|Filter by issue events" +msgstr "" + +msgid "EventFilterBy|Filter by merge events" +msgstr "" + +msgid "EventFilterBy|Filter by push events" +msgstr "" + +msgid "EventFilterBy|Filter by team" +msgstr "" + msgid "Every day (at 4:00am)" msgstr "" @@ -489,6 +513,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Issue events" +msgstr "" + msgid "Jobs for last month" msgstr "" @@ -518,6 +545,12 @@ msgstr "" msgid "Last commit" msgstr "" +msgid "LastPushEvent|You pushed to" +msgstr "" + +msgid "LastPushEvent|at" +msgstr "" + msgid "Learn more in the" msgstr "" @@ -538,6 +571,9 @@ msgstr[1] "" msgid "Median" msgstr "" +msgid "Merge events" +msgstr "" + msgid "MissingSSHKeyWarningLink|add an SSH key" msgstr "" @@ -741,6 +777,9 @@ msgstr "" msgid "Pipeline|with stages" msgstr "" +msgid "Project" +msgstr "" + msgid "Project '%{project_name}' queued for deletion." msgstr "" @@ -774,6 +813,9 @@ msgstr "" msgid "Project home" msgstr "" +msgid "ProjectActivityRSS|Subscribe" +msgstr "" + msgid "ProjectFeature|Disabled" msgstr "" @@ -795,6 +837,9 @@ msgstr "" msgid "ProjectNetworkGraph|Graph" msgstr "" +msgid "Push events" +msgstr "" + msgid "Read more" msgstr "" @@ -925,6 +970,9 @@ msgstr "" msgid "Target Branch" msgstr "" +msgid "Team" +msgstr "" + msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." msgstr "" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5a1db208d5a..2b7c6f7ad33 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3,7 +3,6 @@ # This file is distributed under the same license as the gitlab package. # FIRST AUTHOR , YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po index 4037ff731a2..670ac2d9684 100644 --- a/locale/ja/gitlab.po +++ b/locale/ja/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 個のパイプライン" +msgstr[0] "%d 個のパイプライン" msgid "A collection of graphs regarding Continuous Integration" msgstr "CIについてのグラフ" diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po index 125ca220c81..df850115222 100644 --- a/locale/ko/gitlab.po +++ b/locale/ko/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 파이프라인" +msgstr[0] "%d 파이프라인" msgid "A collection of graphs regarding Continuous Integration" msgstr "지속적인 통합에 관한 그래프 모음" diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po index b25234da030..eb607acf1f4 100644 --- a/locale/zh_CN/gitlab.po +++ b/locale/zh_CN/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 条流水线" +msgstr[0] "%d 条流水线" msgid "A collection of graphs regarding Continuous Integration" msgstr "持续集成数据图" diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po index 8a3a69a0ac0..74c7b464091 100644 --- a/locale/zh_HK/gitlab.po +++ b/locale/zh_HK/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 條流水線" +msgstr[0] "%d 條流水線" msgid "A collection of graphs regarding Continuous Integration" msgstr "相關持續集成的圖像集合" diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po index 91c1cc6bf66..1fc6b79187f 100644 --- a/locale/zh_TW/gitlab.po +++ b/locale/zh_TW/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 條流水線" +msgstr[0] "%d 條流水線" msgid "A collection of graphs regarding Continuous Integration" msgstr "持續整合 (CI) 相關的圖表" @@ -1208,16 +1208,16 @@ msgid "Withdraw Access Request" msgstr "取消權限申請" msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" -msgstr "即將要刪除 %{group_name}。被刪除的群組完全無法救回來喔!真的「100%確定」要這麼做嗎?" +msgstr "即將要刪除 %{group_name}。被刪除的群組無法復原!真的「確定」要這麼做嗎?" msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" -msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案完全無法救回來喔!真的「100%確定」要這麼做嗎?" +msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案無法復原!真的「確定」要這麼做嗎?" msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" -msgstr "將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} 真的「100%確定」要這麼做嗎?" +msgstr "將要刪除本分支專案與主幹 %{forked_from_project} 的所有關聯。 真的「確定」要這麼做嗎?" msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" -msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?" +msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「確定」要這麼做嗎?" msgid "You can only add files when you are on a branch" msgstr "只能在分支 (branch) 上建立檔案" From c34cf3a95286e46a23abdd567aedf2e4a6c72d5e Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 21 Jul 2017 08:55:45 +0200 Subject: [PATCH 05/16] Add documentation about PO-linting --- doc/development/i18n_guide.md | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md index 756535e28bc..bd0ef39ca62 100644 --- a/doc/development/i18n_guide.md +++ b/doc/development/i18n_guide.md @@ -138,6 +138,47 @@ translations. There's no need to generate `.po` files. Translations that aren't used in the source code anymore will be marked with `~#`; these can be removed to keep our translation files clutter-free. +### Validating PO files + +To make sure we keep our translation files up to date, there's a linter that is +running on CI as part of the `static-analysis` job. + +To lint the adjustments in PO files locally you can run `rake gettext:lint`. + +The linter will take the following into account: + +- Valid PO-file syntax +- Variable usage + - Only one unnamed (`%d`) variable, since the order of variables might change + in different languages + - All variables used in the message-id are used in the translation + - There should be no variables used in a translation that aren't in the + message-id +- Errors during translation. + +The errors are grouped per file, and per message ID: + +``` +Errors in `locale/zh_HK/gitlab.po`: + PO-syntax errors + SimplePoParser::ParserErrorSyntax error in lines + Syntax error in msgctxt + Syntax error in msgid + Syntax error in msgstr + Syntax error in message_line + There should be only whitespace until the end of line after the double quote character of a message text. + Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}' + SimplePoParser filtered backtrace: SimplePoParser::ParserError +Errors in `locale/zh_TW/gitlab.po`: + 1 pipeline + <%d 條流水線> is using unknown variables: [%d] + Failure translating to zh_TW with []: too few arguments +``` + +In this output the `locale/zh_HK/gitlab.po` has syntax errors. +The `locale/zh_TW/gitlab.po` has variables that are used in the translation that +aren't in the message with id `1 pipeline`. + ## Working with special content ### Interpolation From 973c697960b9538222c5a83dfe643107313097cc Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Tue, 22 Aug 2017 16:23:43 +0200 Subject: [PATCH 06/16] Add spec for languages without plurals --- lib/gitlab/po_linter.rb | 10 +++++++++- spec/fixtures/multiple_plurals.po | 26 ++++++++++++++++++++++++++ spec/fixtures/no_plurals.po | 24 ++++++++++++++++++++++++ spec/lib/gitlab/po_linter_spec.rb | 16 ++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/multiple_plurals.po create mode 100644 spec/fixtures/no_plurals.po diff --git a/lib/gitlab/po_linter.rb b/lib/gitlab/po_linter.rb index 54594949711..721a111e2a0 100644 --- a/lib/gitlab/po_linter.rb +++ b/lib/gitlab/po_linter.rb @@ -66,7 +66,11 @@ module Gitlab def validate_variables(errors, entry) if entry[:msgid_plural].present? validate_variables_in_message(errors, entry[:msgid], entry['msgstr[0]']) - validate_variables_in_message(errors, entry[:msgid_plural], entry['msgstr[1]']) + + # Validate all plurals + entry.keys.select { |key_name| key_name =~ /msgstr\[[1-9]\]/ }.each do |plural_key| + validate_variables_in_message(errors, entry[:msgid_plural], entry[plural_key]) + end else validate_variables_in_message(errors, entry[:msgid], entry[:msgstr]) end @@ -80,6 +84,10 @@ module Gitlab validate_translation(errors, message_id, required_variables) message_translation = join_message(message_translation) + + # We don't need to validate when the message is empty. + # Translations could fallback to the default, or we could be validating a + # language that does not have plurals. unless message_translation.empty? validate_variable_usage(errors, message_translation, required_variables) end diff --git a/spec/fixtures/multiple_plurals.po b/spec/fixtures/multiple_plurals.po new file mode 100644 index 00000000000..84b17b13ffa --- /dev/null +++ b/spec/fixtures/multiple_plurals.po @@ -0,0 +1,26 @@ +# Arthur Charron , 2017. #zanata +# Huang Tao , 2017. #zanata +# Kohei Ota , 2017. #zanata +# Taisuke Inoue , 2017. #zanata +# Takuya Noguchi , 2017. #zanata +# YANO Tethurou , 2017. #zanata +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2017-08-06 11:23-0400\n" +"Last-Translator: Taisuke Inoue \n" +"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/" +"project/view/GitLab)\n" +"Language: ja\n" +"X-Generator: Zanata 3.9.6\n" +"Plural-Forms: nplurals=3; plural=n\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d個のコミット" +msgstr[1] "%d個のコミット" +msgstr[2] "missing a variable" diff --git a/spec/fixtures/no_plurals.po b/spec/fixtures/no_plurals.po new file mode 100644 index 00000000000..1bfc4ecbb04 --- /dev/null +++ b/spec/fixtures/no_plurals.po @@ -0,0 +1,24 @@ +# Arthur Charron , 2017. #zanata +# Huang Tao , 2017. #zanata +# Kohei Ota , 2017. #zanata +# Taisuke Inoue , 2017. #zanata +# Takuya Noguchi , 2017. #zanata +# YANO Tethurou , 2017. #zanata +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2017-08-06 11:23-0400\n" +"Last-Translator: Taisuke Inoue \n" +"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/" +"project/view/GitLab)\n" +"Language: ja\n" +"X-Generator: Zanata 3.9.6\n" +"Plural-Forms: nplurals=1; plural=0\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d個のコミット" diff --git a/spec/lib/gitlab/po_linter_spec.rb b/spec/lib/gitlab/po_linter_spec.rb index b59cefd294e..75b3163753f 100644 --- a/spec/lib/gitlab/po_linter_spec.rb +++ b/spec/lib/gitlab/po_linter_spec.rb @@ -65,6 +65,22 @@ describe Gitlab::PoLinter do is_expected.to be_empty end end + + context 'with missing plurals' do + let(:po_path) { 'spec/fixtures/no_plurals.po' } + + it 'has no errors' do + is_expected.to be_empty + end + end + + context 'with multiple plurals' do + let(:po_path) { 'spec/fixtures/multiple_plurals.po' } + + it 'has no errors' do + is_expected.not_to be_empty + end + end end describe '#parse_po' do From 1da594d39b4b5d6d905ab9a8325d694b3b0fbec7 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 24 Aug 2017 19:11:35 +0200 Subject: [PATCH 07/16] Check newlines in translations --- lib/gitlab/po_linter.rb | 16 ++++++++++++++++ spec/fixtures/newlines.po | 9 +++++++++ spec/lib/gitlab/po_linter_spec.rb | 18 ++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/po_linter.rb b/lib/gitlab/po_linter.rb index 721a111e2a0..44abea640c3 100644 --- a/lib/gitlab/po_linter.rb +++ b/lib/gitlab/po_linter.rb @@ -61,6 +61,10 @@ module Gitlab if entry[:msgid].is_a?(Array) errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." end + + if translations_in_entry(entry).any? { |translation| translation.is_a?(Array) } + errors << "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + end end def validate_variables(errors, entry) @@ -172,5 +176,17 @@ module Gitlab def join_message(message) Array(message).join end + + def translations_in_entry(entry) + if entry[:msgid_plural].present? + entry.fetch_values(*plural_translation_keys_in_entry(entry)) + else + [entry[:msgstr]] + end + end + + def plural_translation_keys_in_entry(entry) + entry.keys.select { |key| key =~ /msgstr\[\d*\]/ } + end end end diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po index 515d0b3ba99..773d9b23db8 100644 --- a/spec/fixtures/newlines.po +++ b/spec/fixtures/newlines.po @@ -30,3 +30,12 @@ msgstr "" "Va a eliminar %{group_name}.\n" "¡El grupo eliminado NO puede ser restaurado!\n" "¿Estás TOTALMENTE seguro?" + +msgid "With plural" +msgid_plural "with plurals" +msgstr[0] "first" +msgstr[1] "second" +msgstr[2] "" +"with" +"multiple" +"lines" diff --git a/spec/lib/gitlab/po_linter_spec.rb b/spec/lib/gitlab/po_linter_spec.rb index 75b3163753f..74a3d8b95f8 100644 --- a/spec/lib/gitlab/po_linter_spec.rb +++ b/spec/lib/gitlab/po_linter_spec.rb @@ -26,11 +26,25 @@ describe Gitlab::PoLinter do context 'for a translations with newlines' do let(:po_path) { 'spec/fixtures/newlines.po' } - it 'has an error' do + it 'has an error for a normal string' do message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" expected_message = "<#{message_id}> is defined over multiple lines, this breaks some tooling." - is_expected.to include(message_id => [expected_message]) + expect(errors[message_id]).to include(expected_message) + end + + it 'has an error when a translation is defined over multiple lines' do + message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" + expected_message = "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end + + it 'raises an error when a plural translation is defined over multiple lines' do + message_id = 'With plural' + expected_message = "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) end end From 49b38194775a6f0043a0f7f2d01932fcdea69810 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 24 Aug 2017 19:32:53 +0200 Subject: [PATCH 08/16] Only perform `join_message` in `validate_variable_usage` --- lib/gitlab/po_linter.rb | 17 ++++++++--------- spec/lib/gitlab/po_linter_spec.rb | 1 - 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/gitlab/po_linter.rb b/lib/gitlab/po_linter.rb index 44abea640c3..162ba4058e6 100644 --- a/lib/gitlab/po_linter.rb +++ b/lib/gitlab/po_linter.rb @@ -86,15 +86,7 @@ module Gitlab validate_unnamed_variables(errors, required_variables) validate_translation(errors, message_id, required_variables) - - message_translation = join_message(message_translation) - - # We don't need to validate when the message is empty. - # Translations could fallback to the default, or we could be validating a - # language that does not have plurals. - unless message_translation.empty? - validate_variable_usage(errors, message_translation, required_variables) - end + validate_variable_usage(errors, message_translation, required_variables) end def validate_translation(errors, message_id, used_variables) @@ -150,6 +142,13 @@ module Gitlab end def validate_variable_usage(errors, translation, required_variables) + translation = join_message(translation) + + # We don't need to validate when the message is empty. + # Translations could fallback to the default, or we could be validating a + # language that does not have plurals. + return if translation.empty? + found_variables = translation.scan(VARIABLE_REGEX) missing_variables = required_variables - found_variables diff --git a/spec/lib/gitlab/po_linter_spec.rb b/spec/lib/gitlab/po_linter_spec.rb index 74a3d8b95f8..649d5d8127d 100644 --- a/spec/lib/gitlab/po_linter_spec.rb +++ b/spec/lib/gitlab/po_linter_spec.rb @@ -226,7 +226,6 @@ describe Gitlab::PoLinter do expect(errors).to include('Failure translating to en with []: broken') end - it "adds an error when trying to translate with incorrect variables when using unnamed variables" do errors = [] From 0fa0ed7d854761c5f055e421464adb0ff3522411 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 25 Aug 2017 09:04:50 +0200 Subject: [PATCH 09/16] Move `PoLinter` into `Gitlab::I18n` --- lib/gitlab/i18n/po_linter.rb | 193 +++++++++++++++++++ lib/gitlab/po_linter.rb | 191 ------------------ lib/tasks/gettext.rake | 4 +- spec/lib/gitlab/{ => i18n}/po_linter_spec.rb | 2 +- 4 files changed, 196 insertions(+), 194 deletions(-) create mode 100644 lib/gitlab/i18n/po_linter.rb delete mode 100644 lib/gitlab/po_linter.rb rename spec/lib/gitlab/{ => i18n}/po_linter_spec.rb (99%) diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb new file mode 100644 index 00000000000..201d73cfe1d --- /dev/null +++ b/lib/gitlab/i18n/po_linter.rb @@ -0,0 +1,193 @@ +require 'simple_po_parser' + +module Gitlab + module I18n + class PoLinter + attr_reader :po_path, :entries, :locale + + VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze + + def initialize(po_path, locale = I18n.locale.to_s) + @po_path = po_path + @locale = locale + end + + def errors + @errors ||= validate_po + end + + def validate_po + if parse_error = parse_po + return 'PO-syntax errors' => [parse_error] + end + + validate_entries + end + + def parse_po + @entries = SimplePoParser.parse(po_path) + nil + rescue SimplePoParser::ParserError => e + @entries = [] + e.message + end + + def validate_entries + errors = {} + + entries.each do |entry| + # Skip validation of metadata + next if entry[:msgid].empty? + + errors_for_entry = validate_entry(entry) + errors[join_message(entry[:msgid])] = errors_for_entry if errors_for_entry.any? + end + + errors + end + + def validate_entry(entry) + errors = [] + + validate_flags(errors, entry) + validate_variables(errors, entry) + validate_newlines(errors, entry) + + errors + end + + def validate_newlines(errors, entry) + message_id = join_message(entry[:msgid]) + + if entry[:msgid].is_a?(Array) + errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." + end + + if translations_in_entry(entry).any? { |translation| translation.is_a?(Array) } + errors << "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + end + end + + def validate_variables(errors, entry) + if entry[:msgid_plural].present? + validate_variables_in_message(errors, entry[:msgid], entry['msgstr[0]']) + + # Validate all plurals + entry.keys.select { |key_name| key_name =~ /msgstr\[[1-9]\]/ }.each do |plural_key| + validate_variables_in_message(errors, entry[:msgid_plural], entry[plural_key]) + end + else + validate_variables_in_message(errors, entry[:msgid], entry[:msgstr]) + end + end + + def validate_variables_in_message(errors, message_id, message_translation) + message_id = join_message(message_id) + required_variables = message_id.scan(VARIABLE_REGEX) + + validate_unnamed_variables(errors, required_variables) + validate_translation(errors, message_id, required_variables) + validate_variable_usage(errors, message_translation, required_variables) + end + + def validate_translation(errors, message_id, used_variables) + variables = fill_in_variables(used_variables) + + begin + Gitlab::I18n.with_locale(locale) do + translated = if message_id.include?('|') + FastGettext::Translation.s_(message_id) + else + FastGettext::Translation._(message_id) + end + + translated % variables + end + + # `sprintf` could raise an `ArgumentError` when invalid passing something + # other than a Hash when using named variables + # + # `sprintf` could raise `TypeError` when passing a wrong type when using + # unnamed variables + # + # FastGettext::Translation could raise `RuntimeError` (raised as a string), + # or as subclassess `NoTextDomainConfigured` & `InvalidFormat` + # + # `FastGettext::Translation` could raise `ArgumentError` as subclassess + # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter` + rescue ArgumentError, TypeError, RuntimeError => e + errors << "Failure translating to #{locale} with #{variables}: #{e.message}" + end + end + + def fill_in_variables(variables) + if variables.empty? + [] + elsif variables.any? { |variable| unnamed_variable?(variable) } + variables.map do |variable| + variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string + end + else + variables.inject({}) do |hash, variable| + variable_name = variable[/\w+/] + hash[variable_name] = Gitlab::Utils.random_string + hash + end + end + end + + def validate_unnamed_variables(errors, variables) + if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) } + errors << 'is combining multiple unnamed variables' + end + end + + def validate_variable_usage(errors, translation, required_variables) + translation = join_message(translation) + + # We don't need to validate when the message is empty. + # Translations could fallback to the default, or we could be validating a + # language that does not have plurals. + return if translation.empty? + + found_variables = translation.scan(VARIABLE_REGEX) + + missing_variables = required_variables - found_variables + if missing_variables.any? + errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]" + end + + unknown_variables = found_variables - required_variables + if unknown_variables.any? + errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]" + end + end + + def unnamed_variable?(variable_name) + !variable_name.start_with?('%{') + end + + def validate_flags(errors, entry) + if flag = entry[:flag] + errors << "is marked #{flag}" + end + end + + def join_message(message) + Array(message).join + end + + def translations_in_entry(entry) + if entry[:msgid_plural].present? + entry.fetch_values(*plural_translation_keys_in_entry(entry)) + else + [entry[:msgstr]] + end + end + + def plural_translation_keys_in_entry(entry) + entry.keys.select { |key| key =~ /msgstr\[\d*\]/ } + end + end + end +end diff --git a/lib/gitlab/po_linter.rb b/lib/gitlab/po_linter.rb deleted file mode 100644 index 162ba4058e6..00000000000 --- a/lib/gitlab/po_linter.rb +++ /dev/null @@ -1,191 +0,0 @@ -require 'simple_po_parser' - -module Gitlab - class PoLinter - attr_reader :po_path, :entries, :locale - - VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze - - def initialize(po_path, locale = I18n.locale.to_s) - @po_path = po_path - @locale = locale - end - - def errors - @errors ||= validate_po - end - - def validate_po - if parse_error = parse_po - return 'PO-syntax errors' => [parse_error] - end - - validate_entries - end - - def parse_po - @entries = SimplePoParser.parse(po_path) - nil - rescue SimplePoParser::ParserError => e - @entries = [] - e.message - end - - def validate_entries - errors = {} - - entries.each do |entry| - # Skip validation of metadata - next if entry[:msgid].empty? - - errors_for_entry = validate_entry(entry) - errors[join_message(entry[:msgid])] = errors_for_entry if errors_for_entry.any? - end - - errors - end - - def validate_entry(entry) - errors = [] - - validate_flags(errors, entry) - validate_variables(errors, entry) - validate_newlines(errors, entry) - - errors - end - - def validate_newlines(errors, entry) - message_id = join_message(entry[:msgid]) - - if entry[:msgid].is_a?(Array) - errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." - end - - if translations_in_entry(entry).any? { |translation| translation.is_a?(Array) } - errors << "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." - end - end - - def validate_variables(errors, entry) - if entry[:msgid_plural].present? - validate_variables_in_message(errors, entry[:msgid], entry['msgstr[0]']) - - # Validate all plurals - entry.keys.select { |key_name| key_name =~ /msgstr\[[1-9]\]/ }.each do |plural_key| - validate_variables_in_message(errors, entry[:msgid_plural], entry[plural_key]) - end - else - validate_variables_in_message(errors, entry[:msgid], entry[:msgstr]) - end - end - - def validate_variables_in_message(errors, message_id, message_translation) - message_id = join_message(message_id) - required_variables = message_id.scan(VARIABLE_REGEX) - - validate_unnamed_variables(errors, required_variables) - validate_translation(errors, message_id, required_variables) - validate_variable_usage(errors, message_translation, required_variables) - end - - def validate_translation(errors, message_id, used_variables) - variables = fill_in_variables(used_variables) - - begin - Gitlab::I18n.with_locale(locale) do - translated = if message_id.include?('|') - FastGettext::Translation.s_(message_id) - else - FastGettext::Translation._(message_id) - end - - translated % variables - end - - # `sprintf` could raise an `ArgumentError` when invalid passing something - # other than a Hash when using named variables - # - # `sprintf` could raise `TypeError` when passing a wrong type when using - # unnamed variables - # - # FastGettext::Translation could raise `RuntimeError` (raised as a string), - # or as subclassess `NoTextDomainConfigured` & `InvalidFormat` - # - # `FastGettext::Translation` could raise `ArgumentError` as subclassess - # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter` - rescue ArgumentError, TypeError, RuntimeError => e - errors << "Failure translating to #{locale} with #{variables}: #{e.message}" - end - end - - def fill_in_variables(variables) - if variables.empty? - [] - elsif variables.any? { |variable| unnamed_variable?(variable) } - variables.map do |variable| - variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string - end - else - variables.inject({}) do |hash, variable| - variable_name = variable[/\w+/] - hash[variable_name] = Gitlab::Utils.random_string - hash - end - end - end - - def validate_unnamed_variables(errors, variables) - if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) } - errors << 'is combining multiple unnamed variables' - end - end - - def validate_variable_usage(errors, translation, required_variables) - translation = join_message(translation) - - # We don't need to validate when the message is empty. - # Translations could fallback to the default, or we could be validating a - # language that does not have plurals. - return if translation.empty? - - found_variables = translation.scan(VARIABLE_REGEX) - - missing_variables = required_variables - found_variables - if missing_variables.any? - errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]" - end - - unknown_variables = found_variables - required_variables - if unknown_variables.any? - errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]" - end - end - - def unnamed_variable?(variable_name) - !variable_name.start_with?('%{') - end - - def validate_flags(errors, entry) - if flag = entry[:flag] - errors << "is marked #{flag}" - end - end - - def join_message(message) - Array(message).join - end - - def translations_in_entry(entry) - if entry[:msgid_plural].present? - entry.fetch_values(*plural_translation_keys_in_entry(entry)) - else - [entry[:msgstr]] - end - end - - def plural_translation_keys_in_entry(entry) - entry.keys.select { |key| key =~ /msgstr\[\d*\]/ } - end - end -end diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index b75da6bf2fc..e1491f29b5e 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -28,11 +28,11 @@ namespace :gettext do linters = files.map do |file| locale = File.basename(File.dirname(file)) - Gitlab::PoLinter.new(file, locale) + Gitlab::I18n::PoLinter.new(file, locale) end pot_file = Rails.root.join('locale/gitlab.pot') - linters.unshift(Gitlab::PoLinter.new(pot_file)) + linters.unshift(Gitlab::I18n::PoLinter.new(pot_file)) failed_linters = linters.select { |linter| linter.errors.any? } diff --git a/spec/lib/gitlab/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb similarity index 99% rename from spec/lib/gitlab/po_linter_spec.rb rename to spec/lib/gitlab/i18n/po_linter_spec.rb index 649d5d8127d..a8e9e4377b6 100644 --- a/spec/lib/gitlab/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::PoLinter do +describe Gitlab::I18n::PoLinter do let(:linter) { described_class.new(po_path) } let(:po_path) { 'spec/fixtures/valid.po' } From cdaf1072daecd628a89f019b701bc0a2fa27c20e Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 25 Aug 2017 11:23:48 +0200 Subject: [PATCH 10/16] Move detailed information of an entry into a separate class --- lib/gitlab/i18n/po_entry.rb | 66 ++++++++++++++++++++++++ lib/gitlab/i18n/po_linter.rb | 40 ++++----------- spec/lib/gitlab/i18n/po_entry_spec.rb | 71 ++++++++++++++++++++++++++ spec/lib/gitlab/i18n/po_linter_spec.rb | 10 ++-- 4 files changed, 153 insertions(+), 34 deletions(-) create mode 100644 lib/gitlab/i18n/po_entry.rb create mode 100644 spec/lib/gitlab/i18n/po_entry_spec.rb diff --git a/lib/gitlab/i18n/po_entry.rb b/lib/gitlab/i18n/po_entry.rb new file mode 100644 index 00000000000..aabb477bbea --- /dev/null +++ b/lib/gitlab/i18n/po_entry.rb @@ -0,0 +1,66 @@ +module Gitlab + module I18n + class PoEntry + attr_reader :entry_data + + def initialize(entry_data) + @entry_data = entry_data + end + + def msgid + entry_data[:msgid] + end + + def metadata? + msgid.empty? + end + + def plural_id + entry_data[:msgid_plural] + end + + def plural? + plural_id.present? + end + + def singular_translation + plural? ? entry_data['msgstr[0]'] : entry_data[:msgstr] + end + + def all_translations + @all_translations ||= entry_data.fetch_values(*translation_keys) + end + + def plural_translations + return [] unless plural? + + # The singular translation is used if there's only translation. This is + # the case for languages without plurals. + return all_translations if all_translations.size == 1 + + entry_data.fetch_values(*plural_translation_keys) + end + + def flag + entry_data[:flag] + end + + private + + def plural_translation_keys + @plural_translation_keys ||= translation_keys.select do |key| + plural_index = key.scan(/\d+/).first.to_i + plural_index > 0 + end + end + + def translation_keys + @translation_keys ||= if plural? + entry_data.keys.select { |key| key =~ /msgstr\[\d+\]/ } + else + [:msgstr] + end + end + end + end +end diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index 201d73cfe1d..2f6965a19aa 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -25,7 +25,7 @@ module Gitlab end def parse_po - @entries = SimplePoParser.parse(po_path) + @entries = SimplePoParser.parse(po_path).map { |data| Gitlab::I18n::PoEntry.new(data) } nil rescue SimplePoParser::ParserError => e @entries = [] @@ -36,11 +36,10 @@ module Gitlab errors = {} entries.each do |entry| - # Skip validation of metadata - next if entry[:msgid].empty? + next if entry.metadata? errors_for_entry = validate_entry(entry) - errors[join_message(entry[:msgid])] = errors_for_entry if errors_for_entry.any? + errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any? end errors @@ -57,27 +56,24 @@ module Gitlab end def validate_newlines(errors, entry) - message_id = join_message(entry[:msgid]) + message_id = join_message(entry.msgid) - if entry[:msgid].is_a?(Array) + if entry.msgid.is_a?(Array) errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." end - if translations_in_entry(entry).any? { |translation| translation.is_a?(Array) } + if entry.all_translations.any? { |translation| translation.is_a?(Array) } errors << "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." end end def validate_variables(errors, entry) - if entry[:msgid_plural].present? - validate_variables_in_message(errors, entry[:msgid], entry['msgstr[0]']) + validate_variables_in_message(errors, entry.msgid, entry.singular_translation) - # Validate all plurals - entry.keys.select { |key_name| key_name =~ /msgstr\[[1-9]\]/ }.each do |plural_key| - validate_variables_in_message(errors, entry[:msgid_plural], entry[plural_key]) + if entry.plural? + entry.plural_translations.each do |translation| + validate_variables_in_message(errors, entry.plural_id, translation) end - else - validate_variables_in_message(errors, entry[:msgid], entry[:msgstr]) end end @@ -168,26 +164,12 @@ module Gitlab end def validate_flags(errors, entry) - if flag = entry[:flag] - errors << "is marked #{flag}" - end + errors << "is marked #{entry.flag}" if entry.flag end def join_message(message) Array(message).join end - - def translations_in_entry(entry) - if entry[:msgid_plural].present? - entry.fetch_values(*plural_translation_keys_in_entry(entry)) - else - [entry[:msgstr]] - end - end - - def plural_translation_keys_in_entry(entry) - entry.keys.select { |key| key =~ /msgstr\[\d*\]/ } - end end end end diff --git a/spec/lib/gitlab/i18n/po_entry_spec.rb b/spec/lib/gitlab/i18n/po_entry_spec.rb new file mode 100644 index 00000000000..0317caedbff --- /dev/null +++ b/spec/lib/gitlab/i18n/po_entry_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe Gitlab::I18n::PoEntry do + describe '#singular_translation' do + it 'returns the normal `msgstr` for translations without plural' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + + it 'returns the first string for entries with plurals' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + end + + describe '#all_translations' do + it 'returns all translations for singular translations' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data) + + expect(entry.all_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations when including plural translations' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data) + + expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes']) + end + end + + describe '#plural_translations' do + it 'returns all translations if there is only one plural' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde' + } + entry = described_class.new(data) + + expect(entry.plural_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations except for the first one if there are multiple' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes', + 'msgstr[2]' => 'Bonjour tous les mondes' + } + entry = described_class.new(data) + + expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) + end + end +end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index a8e9e4377b6..2e155511076 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -127,13 +127,13 @@ describe Gitlab::I18n::PoLinter do describe '#validate_entries' do it 'skips entries without a `msgid`' do - allow(linter).to receive(:entries) { [{ msgid: "" }] } + allow(linter).to receive(:entries) { [Gitlab::I18n::PoEntry.new({ msgid: "" })] } expect(linter.validate_entries).to be_empty end it 'keeps track of errors for entries' do - fake_invalid_entry = { msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" } + fake_invalid_entry = Gitlab::I18n::PoEntry.new({ msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }) allow(linter).to receive(:entries) { [fake_invalid_entry] } expect(linter).to receive(:validate_entry) @@ -158,12 +158,12 @@ describe Gitlab::I18n::PoLinter do describe '#validate_variables' do it 'validates both signular and plural in a pluralized string' do - pluralized_entry = { + pluralized_entry = Gitlab::I18n::PoEntry.new({ msgid: 'Hello %{world}', msgid_plural: 'Hello all %{world}', 'msgstr[0]' => 'Bonjour %{world}', 'msgstr[1]' => 'Bonjour tous %{world}' - } + }) expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello %{world}', 'Bonjour %{world}') @@ -174,7 +174,7 @@ describe Gitlab::I18n::PoLinter do end it 'validates the message variables' do - entry = { msgid: 'Hello', msgstr: 'Bonjour' } + entry = Gitlab::I18n::PoEntry.new({ msgid: 'Hello', msgstr: 'Bonjour' }) expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello', 'Bonjour') From c6d969949ef98f1b4aebf38ca7f3ed1e59791d48 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 25 Aug 2017 15:23:51 +0200 Subject: [PATCH 11/16] Validate the number of plurals in an entry --- lib/gitlab/i18n/po_entry.rb | 33 ++++++++++++- lib/gitlab/i18n/po_linter.rb | 23 ++++++--- spec/fixtures/missing_plurals.po | 22 +++++++++ spec/fixtures/no_plurals.po | 24 --------- spec/lib/gitlab/i18n/po_entry_spec.rb | 67 ++++++++++++++++++++++++++ spec/lib/gitlab/i18n/po_linter_spec.rb | 45 ++++++++++++++--- 6 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 spec/fixtures/missing_plurals.po delete mode 100644 spec/fixtures/no_plurals.po diff --git a/lib/gitlab/i18n/po_entry.rb b/lib/gitlab/i18n/po_entry.rb index aabb477bbea..f2a4bfbd1cd 100644 --- a/lib/gitlab/i18n/po_entry.rb +++ b/lib/gitlab/i18n/po_entry.rb @@ -28,11 +28,16 @@ module Gitlab end def all_translations - @all_translations ||= entry_data.fetch_values(*translation_keys) + @all_translations ||= entry_data.fetch_values(*translation_keys).reject(&:empty?) + end + + def translated? + all_translations.any? end def plural_translations return [] unless plural? + return [] unless translated? # The singular translation is used if there's only translation. This is # the case for languages without plurals. @@ -45,8 +50,34 @@ module Gitlab entry_data[:flag] end + def expected_plurals + return nil unless metadata? + return nil unless plural_information + + nplurals = plural_information['nplurals'].to_i + if nplurals > 0 + nplurals + end + end + + # When a translation is a plural, but only has 1 translation, we could be + # talking about a language in which plural and singular is the same thing. + # In which case we always translate as a plural. + def has_singular? + !plural? || all_translations.size > 1 + end + private + def plural_information + return nil unless metadata? + return @plural_information if defined?(@plural_information) + + if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') } + @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)] + end + end + def plural_translation_keys @plural_translation_keys ||= translation_keys.select do |key| plural_index = key.scan(/\d+/).first.to_i diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index 2f6965a19aa..e7c92be1383 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -3,7 +3,7 @@ require 'simple_po_parser' module Gitlab module I18n class PoLinter - attr_reader :po_path, :entries, :locale + attr_reader :po_path, :entries, :metadata, :locale VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze @@ -26,6 +26,7 @@ module Gitlab def parse_po @entries = SimplePoParser.parse(po_path).map { |data| Gitlab::I18n::PoEntry.new(data) } + @metadata = @entries.detect { |entry| entry.metadata? } nil rescue SimplePoParser::ParserError => e @entries = [] @@ -51,24 +52,34 @@ module Gitlab validate_flags(errors, entry) validate_variables(errors, entry) validate_newlines(errors, entry) + validate_number_of_plurals(errors, entry) errors end - def validate_newlines(errors, entry) - message_id = join_message(entry.msgid) + def validate_number_of_plurals(errors, entry) + return unless metadata&.expected_plurals + return unless entry.translated? + if entry.plural? && entry.all_translations.size != metadata.expected_plurals + errors << "should have #{metadata.expected_plurals} #{'translations'.pluralize(metadata.expected_plurals)}" + end + end + + def validate_newlines(errors, entry) if entry.msgid.is_a?(Array) - errors << "<#{message_id}> is defined over multiple lines, this breaks some tooling." + errors << "is defined over multiple lines, this breaks some tooling." end if entry.all_translations.any? { |translation| translation.is_a?(Array) } - errors << "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + errors << "has translations defined over multiple lines, this breaks some tooling." end end def validate_variables(errors, entry) - validate_variables_in_message(errors, entry.msgid, entry.singular_translation) + if entry.has_singular? + validate_variables_in_message(errors, entry.msgid, entry.singular_translation) + end if entry.plural? entry.plural_translations.each do |translation| diff --git a/spec/fixtures/missing_plurals.po b/spec/fixtures/missing_plurals.po new file mode 100644 index 00000000000..09ca0c82718 --- /dev/null +++ b/spec/fixtures/missing_plurals.po @@ -0,0 +1,22 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" diff --git a/spec/fixtures/no_plurals.po b/spec/fixtures/no_plurals.po deleted file mode 100644 index 1bfc4ecbb04..00000000000 --- a/spec/fixtures/no_plurals.po +++ /dev/null @@ -1,24 +0,0 @@ -# Arthur Charron , 2017. #zanata -# Huang Tao , 2017. #zanata -# Kohei Ota , 2017. #zanata -# Taisuke Inoue , 2017. #zanata -# Takuya Noguchi , 2017. #zanata -# YANO Tethurou , 2017. #zanata -msgid "" -msgstr "" -"Project-Id-Version: gitlab 1.0.0\n" -"Report-Msgid-Bugs-To: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2017-08-06 11:23-0400\n" -"Last-Translator: Taisuke Inoue \n" -"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/" -"project/view/GitLab)\n" -"Language: ja\n" -"X-Generator: Zanata 3.9.6\n" -"Plural-Forms: nplurals=1; plural=0\n" - -msgid "%d commit" -msgid_plural "%d commits" -msgstr[0] "%d個のコミット" diff --git a/spec/lib/gitlab/i18n/po_entry_spec.rb b/spec/lib/gitlab/i18n/po_entry_spec.rb index 0317caedbff..e671f3c17a1 100644 --- a/spec/lib/gitlab/i18n/po_entry_spec.rb +++ b/spec/lib/gitlab/i18n/po_entry_spec.rb @@ -68,4 +68,71 @@ describe Gitlab::I18n::PoEntry do expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) end end + + describe '#expected_plurals' do + it 'returns nil when the entry is an actual translation' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data) + + expect(entry.expected_plurals).to be_nil + end + + it 'returns the number of plurals' do + data = { + msgid: "", + msgstr: [ + "", + "Project-Id-Version: gitlab 1.0.0\\n", + "Report-Msgid-Bugs-To: \\n", + "PO-Revision-Date: 2017-07-13 12:10-0500\\n", + "Language-Team: Spanish\\n", + "Language: es\\n", + "MIME-Version: 1.0\\n", + "Content-Type: text/plain; charset=UTF-8\\n", + "Content-Transfer-Encoding: 8bit\\n", + "Plural-Forms: nplurals=2; plural=n != 1;\\n", + "Last-Translator: Bob Van Landuyt \\n", + "X-Generator: Poedit 2.0.2\\n" + ] + } + entry = described_class.new(data) + + expect(entry.expected_plurals).to eq(2) + end + end + + describe '#has_singular?' do + it 'has a singular when the translation is not pluralized' do + data = { + msgid: 'hello world', + msgstr: 'hello' + } + entry = described_class.new(data) + + expect(entry).to have_singular + end + + it 'has a singular when plural and singular are separately defined' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello world', + "msgstr[1]" => 'hello worlds' + } + entry = described_class.new(data) + + expect(entry).to have_singular + end + + it 'does not have a separate singular if the plural string only has one translation' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello worlds' + } + entry = described_class.new(data) + + expect(entry).not_to have_singular + end + end end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index 2e155511076..307ea8b2640 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -28,21 +28,21 @@ describe Gitlab::I18n::PoLinter do it 'has an error for a normal string' do message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" - expected_message = "<#{message_id}> is defined over multiple lines, this breaks some tooling." + expected_message = "is defined over multiple lines, this breaks some tooling." expect(errors[message_id]).to include(expected_message) end it 'has an error when a translation is defined over multiple lines' do message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" - expected_message = "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + expected_message = "has translations defined over multiple lines, this breaks some tooling." expect(errors[message_id]).to include(expected_message) end it 'raises an error when a plural translation is defined over multiple lines' do message_id = 'With plural' - expected_message = "<#{message_id}> has translations defined over multiple lines, this breaks some tooling." + expected_message = "has translations defined over multiple lines, this breaks some tooling." expect(errors[message_id]).to include(expected_message) end @@ -81,10 +81,10 @@ describe Gitlab::I18n::PoLinter do end context 'with missing plurals' do - let(:po_path) { 'spec/fixtures/no_plurals.po' } + let(:po_path) { 'spec/fixtures/missing_plurals.po' } it 'has no errors' do - is_expected.to be_empty + is_expected.not_to be_empty end end @@ -151,13 +151,33 @@ describe Gitlab::I18n::PoLinter do expect(linter).to receive(:validate_flags).with([], fake_entry) expect(linter).to receive(:validate_variables).with([], fake_entry) expect(linter).to receive(:validate_newlines).with([], fake_entry) + expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry) linter.validate_entry(fake_entry) end end + describe '#validate_number_of_plurals' do + it 'validates when there are an incorrect number of translations' do + fake_metadata = double + allow(fake_metadata).to receive(:expected_plurals).and_return(2) + allow(linter).to receive(:metadata).and_return(fake_metadata) + + fake_entry = Gitlab::I18n::PoEntry.new( + msgid: 'the singular', + msgid_plural: 'the plural', + 'msgstr[0]' => 'the singular' + ) + errors = [] + + linter.validate_number_of_plurals(errors, fake_entry) + + expect(errors).to include('should have 2 translations') + end + end + describe '#validate_variables' do - it 'validates both signular and plural in a pluralized string' do + it 'validates both signular and plural in a pluralized string when the entry has a singular' do pluralized_entry = Gitlab::I18n::PoEntry.new({ msgid: 'Hello %{world}', msgid_plural: 'Hello all %{world}', @@ -173,6 +193,19 @@ describe Gitlab::I18n::PoLinter do linter.validate_variables([], pluralized_entry) end + it 'only validates plural when there is no separate singular' do + pluralized_entry = Gitlab::I18n::PoEntry.new({ + msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}' + }) + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello all %{world}', 'Bonjour %{world}') + + linter.validate_variables([], pluralized_entry) + end + it 'validates the message variables' do entry = Gitlab::I18n::PoEntry.new({ msgid: 'Hello', msgstr: 'Bonjour' }) From f35a5d0d9919810b14d95808f099a3c652fb17b9 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 30 Aug 2017 10:34:13 +0300 Subject: [PATCH 12/16] Split translation & metadata entries into classes --- lib/gitlab/i18n/metadata_entry.rb | 24 ++++ lib/gitlab/i18n/po_entry.rb | 94 ++---------- lib/gitlab/i18n/po_linter.rb | 24 ++-- lib/gitlab/i18n/translation_entry.rb | 68 +++++++++ spec/lib/gitlab/i18n/metadata_entry_spec.rb | 28 ++++ spec/lib/gitlab/i18n/po_entry_spec.rb | 134 +----------------- spec/lib/gitlab/i18n/po_linter_spec.rb | 31 ++-- .../lib/gitlab/i18n/translation_entry_spec.rb | 106 ++++++++++++++ 8 files changed, 271 insertions(+), 238 deletions(-) create mode 100644 lib/gitlab/i18n/metadata_entry.rb create mode 100644 lib/gitlab/i18n/translation_entry.rb create mode 100644 spec/lib/gitlab/i18n/metadata_entry_spec.rb create mode 100644 spec/lib/gitlab/i18n/translation_entry_spec.rb diff --git a/lib/gitlab/i18n/metadata_entry.rb b/lib/gitlab/i18n/metadata_entry.rb new file mode 100644 index 00000000000..3f9cbae62c8 --- /dev/null +++ b/lib/gitlab/i18n/metadata_entry.rb @@ -0,0 +1,24 @@ +module Gitlab + module I18n + class MetadataEntry < PoEntry + def expected_plurals + return nil unless plural_information + + nplurals = plural_information['nplurals'].to_i + if nplurals > 0 + nplurals + end + end + + private + + def plural_information + return @plural_information if defined?(@plural_information) + + if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') } + @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)] + end + end + end + end +end diff --git a/lib/gitlab/i18n/po_entry.rb b/lib/gitlab/i18n/po_entry.rb index f2a4bfbd1cd..014043cfdd6 100644 --- a/lib/gitlab/i18n/po_entry.rb +++ b/lib/gitlab/i18n/po_entry.rb @@ -1,97 +1,19 @@ module Gitlab module I18n class PoEntry + def self.build(entry_data) + if entry_data[:msgid].empty? + MetadataEntry.new(entry_data) + else + TranslationEntry.new(entry_data) + end + end + attr_reader :entry_data def initialize(entry_data) @entry_data = entry_data end - - def msgid - entry_data[:msgid] - end - - def metadata? - msgid.empty? - end - - def plural_id - entry_data[:msgid_plural] - end - - def plural? - plural_id.present? - end - - def singular_translation - plural? ? entry_data['msgstr[0]'] : entry_data[:msgstr] - end - - def all_translations - @all_translations ||= entry_data.fetch_values(*translation_keys).reject(&:empty?) - end - - def translated? - all_translations.any? - end - - def plural_translations - return [] unless plural? - return [] unless translated? - - # The singular translation is used if there's only translation. This is - # the case for languages without plurals. - return all_translations if all_translations.size == 1 - - entry_data.fetch_values(*plural_translation_keys) - end - - def flag - entry_data[:flag] - end - - def expected_plurals - return nil unless metadata? - return nil unless plural_information - - nplurals = plural_information['nplurals'].to_i - if nplurals > 0 - nplurals - end - end - - # When a translation is a plural, but only has 1 translation, we could be - # talking about a language in which plural and singular is the same thing. - # In which case we always translate as a plural. - def has_singular? - !plural? || all_translations.size > 1 - end - - private - - def plural_information - return nil unless metadata? - return @plural_information if defined?(@plural_information) - - if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') } - @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)] - end - end - - def plural_translation_keys - @plural_translation_keys ||= translation_keys.select do |key| - plural_index = key.scan(/\d+/).first.to_i - plural_index > 0 - end - end - - def translation_keys - @translation_keys ||= if plural? - entry_data.keys.select { |key| key =~ /msgstr\[\d+\]/ } - else - [:msgstr] - end - end end end end diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index e7c92be1383..0dc96ac7b9b 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -3,7 +3,7 @@ require 'simple_po_parser' module Gitlab module I18n class PoLinter - attr_reader :po_path, :entries, :metadata, :locale + attr_reader :po_path, :translation_entries, :metadata_entry, :locale VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze @@ -25,20 +25,23 @@ module Gitlab end def parse_po - @entries = SimplePoParser.parse(po_path).map { |data| Gitlab::I18n::PoEntry.new(data) } - @metadata = @entries.detect { |entry| entry.metadata? } + entries = SimplePoParser.parse(po_path).map { |data| Gitlab::I18n::PoEntry.build(data) } + + # The first entry is the metadata entry if there is one. + # This is an entry when empty `msgid` + @metadata_entry = entries.shift if entries.first.is_a?(Gitlab::I18n::MetadataEntry) + @translation_entries = entries + nil rescue SimplePoParser::ParserError => e - @entries = [] + @translation_entries = [] e.message end def validate_entries errors = {} - entries.each do |entry| - next if entry.metadata? - + translation_entries.each do |entry| errors_for_entry = validate_entry(entry) errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any? end @@ -58,11 +61,12 @@ module Gitlab end def validate_number_of_plurals(errors, entry) - return unless metadata&.expected_plurals + return unless metadata_entry&.expected_plurals return unless entry.translated? - if entry.plural? && entry.all_translations.size != metadata.expected_plurals - errors << "should have #{metadata.expected_plurals} #{'translations'.pluralize(metadata.expected_plurals)}" + if entry.plural? && entry.all_translations.size != metadata_entry.expected_plurals + errors << "should have #{metadata_entry.expected_plurals} "\ + "#{'translations'.pluralize(metadata_entry.expected_plurals)}" end end diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb new file mode 100644 index 00000000000..98095177994 --- /dev/null +++ b/lib/gitlab/i18n/translation_entry.rb @@ -0,0 +1,68 @@ +module Gitlab + module I18n + class TranslationEntry < PoEntry + def msgid + entry_data[:msgid] + end + + def plural_id + entry_data[:msgid_plural] + end + + def plural? + plural_id.present? + end + + def singular_translation + plural? ? entry_data['msgstr[0]'] : entry_data[:msgstr] + end + + def all_translations + @all_translations ||= entry_data.fetch_values(*translation_keys).reject(&:empty?) + end + + def translated? + all_translations.any? + end + + def plural_translations + return [] unless plural? + return [] unless translated? + + # The singular translation is used if there's only translation. This is + # the case for languages without plurals. + return all_translations if all_translations.size == 1 + + entry_data.fetch_values(*plural_translation_keys) + end + + def flag + entry_data[:flag] + end + + # When a translation is a plural, but only has 1 translation, we could be + # talking about a language in which plural and singular is the same thing. + # In which case we always translate as a plural. + def has_singular? + !plural? || all_translations.size > 1 + end + + private + + def plural_translation_keys + @plural_translation_keys ||= translation_keys.select do |key| + plural_index = key.scan(/\d+/).first.to_i + plural_index > 0 + end + end + + def translation_keys + @translation_keys ||= if plural? + entry_data.keys.select { |key| key =~ /msgstr\[\d+\]/ } + else + [:msgstr] + end + end + end + end +end diff --git a/spec/lib/gitlab/i18n/metadata_entry_spec.rb b/spec/lib/gitlab/i18n/metadata_entry_spec.rb new file mode 100644 index 00000000000..5bc16e1806e --- /dev/null +++ b/spec/lib/gitlab/i18n/metadata_entry_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Gitlab::I18n::MetadataEntry do + describe '#expected_plurals' do + it 'returns the number of plurals' do + data = { + msgid: "", + msgstr: [ + "", + "Project-Id-Version: gitlab 1.0.0\\n", + "Report-Msgid-Bugs-To: \\n", + "PO-Revision-Date: 2017-07-13 12:10-0500\\n", + "Language-Team: Spanish\\n", + "Language: es\\n", + "MIME-Version: 1.0\\n", + "Content-Type: text/plain; charset=UTF-8\\n", + "Content-Transfer-Encoding: 8bit\\n", + "Plural-Forms: nplurals=2; plural=n != 1;\\n", + "Last-Translator: Bob Van Landuyt \\n", + "X-Generator: Poedit 2.0.2\\n" + ] + } + entry = described_class.new(data) + + expect(entry.expected_plurals).to eq(2) + end + end +end diff --git a/spec/lib/gitlab/i18n/po_entry_spec.rb b/spec/lib/gitlab/i18n/po_entry_spec.rb index e671f3c17a1..4be1b3d4383 100644 --- a/spec/lib/gitlab/i18n/po_entry_spec.rb +++ b/spec/lib/gitlab/i18n/po_entry_spec.rb @@ -1,138 +1,18 @@ require 'spec_helper' describe Gitlab::I18n::PoEntry do - describe '#singular_translation' do - it 'returns the normal `msgstr` for translations without plural' do - data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } - entry = described_class.new(data) + describe '.build' do + it 'builds a metadata entry when the msgid is empty' do + entry = described_class.build(msgid: '') - expect(entry.singular_translation).to eq('Bonjour monde') + expect(entry).to be_kind_of(Gitlab::I18n::MetadataEntry) end - it 'returns the first string for entries with plurals' do - data = { - msgid: 'Hello world', - msgid_plural: 'Hello worlds', - 'msgstr[0]' => 'Bonjour monde', - 'msgstr[1]' => 'Bonjour mondes' - } - entry = described_class.new(data) + it 'builds a translation entry when the msgid is empty' do + entry = described_class.build(msgid: 'Hello world') - expect(entry.singular_translation).to eq('Bonjour monde') - end - end - - describe '#all_translations' do - it 'returns all translations for singular translations' do - data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } - entry = described_class.new(data) - - expect(entry.all_translations).to eq(['Bonjour monde']) + expect(entry).to be_kind_of(Gitlab::I18n::TranslationEntry) end - it 'returns all translations when including plural translations' do - data = { - msgid: 'Hello world', - msgid_plural: 'Hello worlds', - 'msgstr[0]' => 'Bonjour monde', - 'msgstr[1]' => 'Bonjour mondes' - } - entry = described_class.new(data) - - expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes']) - end - end - - describe '#plural_translations' do - it 'returns all translations if there is only one plural' do - data = { - msgid: 'Hello world', - msgid_plural: 'Hello worlds', - 'msgstr[0]' => 'Bonjour monde' - } - entry = described_class.new(data) - - expect(entry.plural_translations).to eq(['Bonjour monde']) - end - - it 'returns all translations except for the first one if there are multiple' do - data = { - msgid: 'Hello world', - msgid_plural: 'Hello worlds', - 'msgstr[0]' => 'Bonjour monde', - 'msgstr[1]' => 'Bonjour mondes', - 'msgstr[2]' => 'Bonjour tous les mondes' - } - entry = described_class.new(data) - - expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) - end - end - - describe '#expected_plurals' do - it 'returns nil when the entry is an actual translation' do - data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } - entry = described_class.new(data) - - expect(entry.expected_plurals).to be_nil - end - - it 'returns the number of plurals' do - data = { - msgid: "", - msgstr: [ - "", - "Project-Id-Version: gitlab 1.0.0\\n", - "Report-Msgid-Bugs-To: \\n", - "PO-Revision-Date: 2017-07-13 12:10-0500\\n", - "Language-Team: Spanish\\n", - "Language: es\\n", - "MIME-Version: 1.0\\n", - "Content-Type: text/plain; charset=UTF-8\\n", - "Content-Transfer-Encoding: 8bit\\n", - "Plural-Forms: nplurals=2; plural=n != 1;\\n", - "Last-Translator: Bob Van Landuyt \\n", - "X-Generator: Poedit 2.0.2\\n" - ] - } - entry = described_class.new(data) - - expect(entry.expected_plurals).to eq(2) - end - end - - describe '#has_singular?' do - it 'has a singular when the translation is not pluralized' do - data = { - msgid: 'hello world', - msgstr: 'hello' - } - entry = described_class.new(data) - - expect(entry).to have_singular - end - - it 'has a singular when plural and singular are separately defined' do - data = { - msgid: 'hello world', - msgid_plural: 'hello worlds', - "msgstr[0]" => 'hello world', - "msgstr[1]" => 'hello worlds' - } - entry = described_class.new(data) - - expect(entry).to have_singular - end - - it 'does not have a separate singular if the plural string only has one translation' do - data = { - msgid: 'hello world', - msgid_plural: 'hello worlds', - "msgstr[0]" => 'hello worlds' - } - entry = described_class.new(data) - - expect(entry).not_to have_singular - end end end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index 307ea8b2640..c40e8ff63bb 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -102,7 +102,8 @@ describe Gitlab::I18n::PoLinter do it 'fills in the entries' do linter.parse_po - expect(linter.entries).not_to be_empty + expect(linter.translation_entries).not_to be_empty + expect(linter.metadata_entry).to be_kind_of(Gitlab::I18n::MetadataEntry) end it 'does not have errors' do @@ -120,21 +121,18 @@ describe Gitlab::I18n::PoLinter do it 'sets the entries to an empty array' do linter.parse_po - expect(linter.entries).to eq([]) + expect(linter.translation_entries).to eq([]) end end end describe '#validate_entries' do - it 'skips entries without a `msgid`' do - allow(linter).to receive(:entries) { [Gitlab::I18n::PoEntry.new({ msgid: "" })] } - - expect(linter.validate_entries).to be_empty - end - it 'keeps track of errors for entries' do - fake_invalid_entry = Gitlab::I18n::PoEntry.new({ msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }) - allow(linter).to receive(:entries) { [fake_invalid_entry] } + fake_invalid_entry = Gitlab::I18n::TranslationEntry.new( + msgid: "Hello %{world}", + msgstr: "Bonjour %{monde}" + ) + allow(linter).to receive(:translation_entries) { [fake_invalid_entry] } expect(linter).to receive(:validate_entry) .with(fake_invalid_entry) @@ -161,9 +159,9 @@ describe Gitlab::I18n::PoLinter do it 'validates when there are an incorrect number of translations' do fake_metadata = double allow(fake_metadata).to receive(:expected_plurals).and_return(2) - allow(linter).to receive(:metadata).and_return(fake_metadata) + allow(linter).to receive(:metadata_entry).and_return(fake_metadata) - fake_entry = Gitlab::I18n::PoEntry.new( + fake_entry = Gitlab::I18n::TranslationEntry.new( msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' @@ -178,7 +176,7 @@ describe Gitlab::I18n::PoLinter do describe '#validate_variables' do it 'validates both signular and plural in a pluralized string when the entry has a singular' do - pluralized_entry = Gitlab::I18n::PoEntry.new({ + pluralized_entry = Gitlab::I18n::TranslationEntry.new({ msgid: 'Hello %{world}', msgid_plural: 'Hello all %{world}', 'msgstr[0]' => 'Bonjour %{world}', @@ -194,7 +192,7 @@ describe Gitlab::I18n::PoLinter do end it 'only validates plural when there is no separate singular' do - pluralized_entry = Gitlab::I18n::PoEntry.new({ + pluralized_entry = Gitlab::I18n::TranslationEntry.new({ msgid: 'Hello %{world}', msgid_plural: 'Hello all %{world}', 'msgstr[0]' => 'Bonjour %{world}' @@ -207,7 +205,10 @@ describe Gitlab::I18n::PoLinter do end it 'validates the message variables' do - entry = Gitlab::I18n::PoEntry.new({ msgid: 'Hello', msgstr: 'Bonjour' }) + entry = Gitlab::I18n::TranslationEntry.new( + msgid: 'Hello', + msgstr: 'Bonjour' + ) expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello', 'Bonjour') diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb new file mode 100644 index 00000000000..7d97942a1d5 --- /dev/null +++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe Gitlab::I18n::TranslationEntry do + describe '#singular_translation' do + it 'returns the normal `msgstr` for translations without plural' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + + it 'returns the first string for entries with plurals' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + end + + describe '#all_translations' do + it 'returns all translations for singular translations' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data) + + expect(entry.all_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations when including plural translations' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data) + + expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes']) + end + end + + describe '#plural_translations' do + it 'returns all translations if there is only one plural' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde' + } + entry = described_class.new(data) + + expect(entry.plural_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations except for the first one if there are multiple' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes', + 'msgstr[2]' => 'Bonjour tous les mondes' + } + entry = described_class.new(data) + + expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) + end + end + + describe '#has_singular?' do + it 'has a singular when the translation is not pluralized' do + data = { + msgid: 'hello world', + msgstr: 'hello' + } + entry = described_class.new(data) + + expect(entry).to have_singular + end + + it 'has a singular when plural and singular are separately defined' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello world', + "msgstr[1]" => 'hello worlds' + } + entry = described_class.new(data) + + expect(entry).to have_singular + end + + it 'does not have a separate singular if the plural string only has one translation' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello worlds' + } + entry = described_class.new(data) + + expect(entry).not_to have_singular + end + end +end From 2c4f9b7a73cf5de875b2c77880c040e845960a9a Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 30 Aug 2017 10:53:23 +0300 Subject: [PATCH 13/16] Check for newlines in different methods on TranslationEntry --- lib/gitlab/i18n/po_linter.rb | 8 ++++-- lib/gitlab/i18n/translation_entry.rb | 12 +++++++++ spec/fixtures/newlines.po | 7 +++++ spec/lib/gitlab/i18n/po_linter_spec.rb | 7 +++++ .../lib/gitlab/i18n/translation_entry_spec.rb | 27 +++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index 0dc96ac7b9b..f5ffc6669e4 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -71,11 +71,15 @@ module Gitlab end def validate_newlines(errors, entry) - if entry.msgid.is_a?(Array) + if entry.msgid_contains_newlines? errors << "is defined over multiple lines, this breaks some tooling." end - if entry.all_translations.any? { |translation| translation.is_a?(Array) } + if entry.plural_id_contains_newlines? + errors << "plural is defined over multiple lines, this breaks some tooling." + end + + if entry.translations_contain_newlines? errors << "has translations defined over multiple lines, this breaks some tooling." end end diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb index 98095177994..4fe8f569f9c 100644 --- a/lib/gitlab/i18n/translation_entry.rb +++ b/lib/gitlab/i18n/translation_entry.rb @@ -47,6 +47,18 @@ module Gitlab !plural? || all_translations.size > 1 end + def msgid_contains_newlines? + msgid.is_a?(Array) + end + + def plural_id_contains_newlines? + plural_id.is_a?(Array) + end + + def translations_contain_newlines? + all_translations.any? { |translation| translation.is_a?(Array) } + end + private def plural_translation_keys diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po index 773d9b23db8..f5bc86f39a7 100644 --- a/spec/fixtures/newlines.po +++ b/spec/fixtures/newlines.po @@ -39,3 +39,10 @@ msgstr[2] "" "with" "multiple" "lines" + +msgid "multiline plural id" +msgid_plural "" +"Plural" +"Id" +msgstr[0] "first" +msgstr[1] "second" diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index c40e8ff63bb..0fa4e05c7b1 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -46,6 +46,13 @@ describe Gitlab::I18n::PoLinter do expect(errors[message_id]).to include(expected_message) end + + it 'raises an error when the plural id is defined over multiple lines' do + message_id = 'multiline plural id' + expected_message = "plural is defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end end context 'with an invalid po' do diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb index 7d97942a1d5..e48ba28be0e 100644 --- a/spec/lib/gitlab/i18n/translation_entry_spec.rb +++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb @@ -103,4 +103,31 @@ describe Gitlab::I18n::TranslationEntry do expect(entry).not_to have_singular end end + + describe '#msgid_contains_newlines'do + it 'is true when the msgid is an array' do + data = { msgid: %w(hello world) } + entry = described_class.new(data) + + expect(entry.msgid_contains_newlines?).to be_truthy + end + end + + describe '#plural_id_contains_newlines'do + it 'is true when the msgid is an array' do + data = { plural_id: %w(hello world) } + entry = described_class.new(data) + + expect(entry.plural_id_contains_newlines?).to be_truthy + end + end + + describe '#translations_contain_newlines'do + it 'is true when the msgid is an array' do + data = { msgstr: %w(hello world) } + entry = described_class.new(data) + + expect(entry.translations_contain_newlines?).to be_truthy + end + end end From abe198723d76cea1b7f151a15789d26a3d22ad4d Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 31 Aug 2017 13:39:41 +0200 Subject: [PATCH 14/16] Take `nplurals` into account when validating translations. --- lib/gitlab/i18n/metadata_entry.rb | 13 +++-- lib/gitlab/i18n/po_entry.rb | 19 -------- lib/gitlab/i18n/po_linter.rb | 23 +++++---- lib/gitlab/i18n/translation_entry.rb | 44 ++++++++--------- spec/fixtures/missing_metadata.po | 4 ++ spec/lib/gitlab/i18n/metadata_entry_spec.rb | 23 +++++++++ spec/lib/gitlab/i18n/po_entry_spec.rb | 18 ------- spec/lib/gitlab/i18n/po_linter_spec.rb | 48 +++++++++++-------- .../lib/gitlab/i18n/translation_entry_spec.rb | 40 ++++++++-------- 9 files changed, 120 insertions(+), 112 deletions(-) delete mode 100644 lib/gitlab/i18n/po_entry.rb create mode 100644 spec/fixtures/missing_metadata.po delete mode 100644 spec/lib/gitlab/i18n/po_entry_spec.rb diff --git a/lib/gitlab/i18n/metadata_entry.rb b/lib/gitlab/i18n/metadata_entry.rb index 3f9cbae62c8..35d57459a3d 100644 --- a/lib/gitlab/i18n/metadata_entry.rb +++ b/lib/gitlab/i18n/metadata_entry.rb @@ -1,13 +1,16 @@ module Gitlab module I18n - class MetadataEntry < PoEntry + class MetadataEntry + attr_reader :entry_data + + def initialize(entry_data) + @entry_data = entry_data + end + def expected_plurals return nil unless plural_information - nplurals = plural_information['nplurals'].to_i - if nplurals > 0 - nplurals - end + plural_information['nplurals'].to_i end private diff --git a/lib/gitlab/i18n/po_entry.rb b/lib/gitlab/i18n/po_entry.rb deleted file mode 100644 index 014043cfdd6..00000000000 --- a/lib/gitlab/i18n/po_entry.rb +++ /dev/null @@ -1,19 +0,0 @@ -module Gitlab - module I18n - class PoEntry - def self.build(entry_data) - if entry_data[:msgid].empty? - MetadataEntry.new(entry_data) - else - TranslationEntry.new(entry_data) - end - end - - attr_reader :entry_data - - def initialize(entry_data) - @entry_data = entry_data - end - end - end -end diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index f5ffc6669e4..c3b1fe582af 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -25,12 +25,19 @@ module Gitlab end def parse_po - entries = SimplePoParser.parse(po_path).map { |data| Gitlab::I18n::PoEntry.build(data) } + entries = SimplePoParser.parse(po_path) # The first entry is the metadata entry if there is one. # This is an entry when empty `msgid` - @metadata_entry = entries.shift if entries.first.is_a?(Gitlab::I18n::MetadataEntry) - @translation_entries = entries + if entries.first[:msgid].empty? + @metadata_entry = Gitlab::I18n::MetadataEntry.new(entries.shift) + else + return 'Missing metadata entry.' + end + + @translation_entries = entries.map do |entry_data| + Gitlab::I18n::TranslationEntry.new(entry_data, metadata_entry.expected_plurals) + end nil rescue SimplePoParser::ParserError => e @@ -64,7 +71,7 @@ module Gitlab return unless metadata_entry&.expected_plurals return unless entry.translated? - if entry.plural? && entry.all_translations.size != metadata_entry.expected_plurals + if entry.has_plural? && entry.all_translations.size != metadata_entry.expected_plurals errors << "should have #{metadata_entry.expected_plurals} "\ "#{'translations'.pluralize(metadata_entry.expected_plurals)}" end @@ -85,11 +92,11 @@ module Gitlab end def validate_variables(errors, entry) - if entry.has_singular? + if entry.has_singular_translation? validate_variables_in_message(errors, entry.msgid, entry.singular_translation) end - if entry.plural? + if entry.has_plural? entry.plural_translations.each do |translation| validate_variables_in_message(errors, entry.plural_id, translation) end @@ -161,8 +168,8 @@ module Gitlab translation = join_message(translation) # We don't need to validate when the message is empty. - # Translations could fallback to the default, or we could be validating a - # language that does not have plurals. + # In this case we fall back to the default, which has all the the + # required variables. return if translation.empty? found_variables = translation.scan(VARIABLE_REGEX) diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb index 4fe8f569f9c..75d5aa0cfe1 100644 --- a/lib/gitlab/i18n/translation_entry.rb +++ b/lib/gitlab/i18n/translation_entry.rb @@ -1,6 +1,13 @@ module Gitlab module I18n - class TranslationEntry < PoEntry + class TranslationEntry + attr_reader :nplurals, :entry_data + + def initialize(entry_data, nplurals) + @entry_data = entry_data + @nplurals = nplurals + end + def msgid entry_data[:msgid] end @@ -9,16 +16,17 @@ module Gitlab entry_data[:msgid_plural] end - def plural? + def has_plural? plural_id.present? end def singular_translation - plural? ? entry_data['msgstr[0]'] : entry_data[:msgstr] + all_translations.first if has_singular_translation? end def all_translations - @all_translations ||= entry_data.fetch_values(*translation_keys).reject(&:empty?) + @all_translations ||= entry_data.fetch_values(*translation_keys) + .reject(&:empty?) end def translated? @@ -26,25 +34,22 @@ module Gitlab end def plural_translations - return [] unless plural? + return [] unless has_plural? return [] unless translated? - # The singular translation is used if there's only translation. This is - # the case for languages without plurals. - return all_translations if all_translations.size == 1 - - entry_data.fetch_values(*plural_translation_keys) + @plural_translations ||= if has_singular_translation? + all_translations.drop(1) + else + all_translations + end end def flag entry_data[:flag] end - # When a translation is a plural, but only has 1 translation, we could be - # talking about a language in which plural and singular is the same thing. - # In which case we always translate as a plural. - def has_singular? - !plural? || all_translations.size > 1 + def has_singular_translation? + nplurals > 1 || !has_plural? end def msgid_contains_newlines? @@ -61,15 +66,8 @@ module Gitlab private - def plural_translation_keys - @plural_translation_keys ||= translation_keys.select do |key| - plural_index = key.scan(/\d+/).first.to_i - plural_index > 0 - end - end - def translation_keys - @translation_keys ||= if plural? + @translation_keys ||= if has_plural? entry_data.keys.select { |key| key =~ /msgstr\[\d+\]/ } else [:msgstr] diff --git a/spec/fixtures/missing_metadata.po b/spec/fixtures/missing_metadata.po new file mode 100644 index 00000000000..b1999c933f1 --- /dev/null +++ b/spec/fixtures/missing_metadata.po @@ -0,0 +1,4 @@ +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" diff --git a/spec/lib/gitlab/i18n/metadata_entry_spec.rb b/spec/lib/gitlab/i18n/metadata_entry_spec.rb index 5bc16e1806e..ab71d6454a9 100644 --- a/spec/lib/gitlab/i18n/metadata_entry_spec.rb +++ b/spec/lib/gitlab/i18n/metadata_entry_spec.rb @@ -24,5 +24,28 @@ describe Gitlab::I18n::MetadataEntry do expect(entry.expected_plurals).to eq(2) end + + it 'returns 0 for the POT-metadata' do + data = { + msgid: "", + msgstr: [ + "", + "Project-Id-Version: gitlab 1.0.0\\n", + "Report-Msgid-Bugs-To: \\n", + "PO-Revision-Date: 2017-07-13 12:10-0500\\n", + "Language-Team: Spanish\\n", + "Language: es\\n", + "MIME-Version: 1.0\\n", + "Content-Type: text/plain; charset=UTF-8\\n", + "Content-Transfer-Encoding: 8bit\\n", + "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n", + "Last-Translator: Bob Van Landuyt \\n", + "X-Generator: Poedit 2.0.2\\n" + ] + } + entry = described_class.new(data) + + expect(entry.expected_plurals).to eq(0) + end end end diff --git a/spec/lib/gitlab/i18n/po_entry_spec.rb b/spec/lib/gitlab/i18n/po_entry_spec.rb deleted file mode 100644 index 4be1b3d4383..00000000000 --- a/spec/lib/gitlab/i18n/po_entry_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'spec_helper' - -describe Gitlab::I18n::PoEntry do - describe '.build' do - it 'builds a metadata entry when the msgid is empty' do - entry = described_class.build(msgid: '') - - expect(entry).to be_kind_of(Gitlab::I18n::MetadataEntry) - end - - it 'builds a translation entry when the msgid is empty' do - entry = described_class.build(msgid: 'Hello world') - - expect(entry).to be_kind_of(Gitlab::I18n::TranslationEntry) - end - - end -end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index 0fa4e05c7b1..97a8c105264 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -69,6 +69,14 @@ describe Gitlab::I18n::PoLinter do end end + context 'with missing metadata' do + let(:po_path) { 'spec/fixtures/missing_metadata.po' } + + it 'returns the an error' do + is_expected.to include('PO-syntax errors' => a_kind_of(Array)) + end + end + context 'with a valid po' do it 'parses the file' do expect(linter).to receive(:parse_po).and_call_original @@ -90,7 +98,7 @@ describe Gitlab::I18n::PoLinter do context 'with missing plurals' do let(:po_path) { 'spec/fixtures/missing_plurals.po' } - it 'has no errors' do + it 'has errors' do is_expected.not_to be_empty end end @@ -136,8 +144,7 @@ describe Gitlab::I18n::PoLinter do describe '#validate_entries' do it 'keeps track of errors for entries' do fake_invalid_entry = Gitlab::I18n::TranslationEntry.new( - msgid: "Hello %{world}", - msgstr: "Bonjour %{monde}" + { msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }, 2 ) allow(linter).to receive(:translation_entries) { [fake_invalid_entry] } @@ -169,9 +176,8 @@ describe Gitlab::I18n::PoLinter do allow(linter).to receive(:metadata_entry).and_return(fake_metadata) fake_entry = Gitlab::I18n::TranslationEntry.new( - msgid: 'the singular', - msgid_plural: 'the plural', - 'msgstr[0]' => 'the singular' + { msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' }, + 2 ) errors = [] @@ -183,27 +189,31 @@ describe Gitlab::I18n::PoLinter do describe '#validate_variables' do it 'validates both signular and plural in a pluralized string when the entry has a singular' do - pluralized_entry = Gitlab::I18n::TranslationEntry.new({ - msgid: 'Hello %{world}', - msgid_plural: 'Hello all %{world}', - 'msgstr[0]' => 'Bonjour %{world}', - 'msgstr[1]' => 'Bonjour tous %{world}' - }) + pluralized_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}', + 'msgstr[1]' => 'Bonjour tous %{world}' }, + 2 + ) expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello %{world}', 'Bonjour %{world}') + .and_call_original expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello all %{world}', 'Bonjour tous %{world}') + .and_call_original linter.validate_variables([], pluralized_entry) end it 'only validates plural when there is no separate singular' do - pluralized_entry = Gitlab::I18n::TranslationEntry.new({ - msgid: 'Hello %{world}', - msgid_plural: 'Hello all %{world}', - 'msgstr[0]' => 'Bonjour %{world}' - }) + pluralized_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}' }, + 1 + ) expect(linter).to receive(:validate_variables_in_message) .with([], 'Hello all %{world}', 'Bonjour %{world}') @@ -213,8 +223,8 @@ describe Gitlab::I18n::PoLinter do it 'validates the message variables' do entry = Gitlab::I18n::TranslationEntry.new( - msgid: 'Hello', - msgstr: 'Bonjour' + { msgid: 'Hello', msgstr: 'Bonjour' }, + 2 ) expect(linter).to receive(:validate_variables_in_message) diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb index e48ba28be0e..d2d3ec03c6d 100644 --- a/spec/lib/gitlab/i18n/translation_entry_spec.rb +++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::I18n::TranslationEntry do describe '#singular_translation' do it 'returns the normal `msgstr` for translations without plural' do data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.singular_translation).to eq('Bonjour monde') end @@ -16,7 +16,7 @@ describe Gitlab::I18n::TranslationEntry do 'msgstr[0]' => 'Bonjour monde', 'msgstr[1]' => 'Bonjour mondes' } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.singular_translation).to eq('Bonjour monde') end @@ -25,7 +25,7 @@ describe Gitlab::I18n::TranslationEntry do describe '#all_translations' do it 'returns all translations for singular translations' do data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.all_translations).to eq(['Bonjour monde']) end @@ -37,7 +37,7 @@ describe Gitlab::I18n::TranslationEntry do 'msgstr[0]' => 'Bonjour monde', 'msgstr[1]' => 'Bonjour mondes' } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes']) end @@ -50,7 +50,7 @@ describe Gitlab::I18n::TranslationEntry do msgid_plural: 'Hello worlds', 'msgstr[0]' => 'Bonjour monde' } - entry = described_class.new(data) + entry = described_class.new(data, 1) expect(entry.plural_translations).to eq(['Bonjour monde']) end @@ -63,21 +63,21 @@ describe Gitlab::I18n::TranslationEntry do 'msgstr[1]' => 'Bonjour mondes', 'msgstr[2]' => 'Bonjour tous les mondes' } - entry = described_class.new(data) + entry = described_class.new(data, 3) expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) end end - describe '#has_singular?' do + describe '#has_singular_translation?' do it 'has a singular when the translation is not pluralized' do data = { msgid: 'hello world', msgstr: 'hello' } - entry = described_class.new(data) + entry = described_class.new(data, 2) - expect(entry).to have_singular + expect(entry).to have_singular_translation end it 'has a singular when plural and singular are separately defined' do @@ -87,9 +87,9 @@ describe Gitlab::I18n::TranslationEntry do "msgstr[0]" => 'hello world', "msgstr[1]" => 'hello worlds' } - entry = described_class.new(data) + entry = described_class.new(data, 2) - expect(entry).to have_singular + expect(entry).to have_singular_translation end it 'does not have a separate singular if the plural string only has one translation' do @@ -98,34 +98,34 @@ describe Gitlab::I18n::TranslationEntry do msgid_plural: 'hello worlds', "msgstr[0]" => 'hello worlds' } - entry = described_class.new(data) + entry = described_class.new(data, 1) - expect(entry).not_to have_singular + expect(entry).not_to have_singular_translation end end - describe '#msgid_contains_newlines'do + describe '#msgid_contains_newlines' do it 'is true when the msgid is an array' do data = { msgid: %w(hello world) } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.msgid_contains_newlines?).to be_truthy end end - describe '#plural_id_contains_newlines'do + describe '#plural_id_contains_newlines' do it 'is true when the msgid is an array' do - data = { plural_id: %w(hello world) } - entry = described_class.new(data) + data = { msgid_plural: %w(hello world) } + entry = described_class.new(data, 2) expect(entry.plural_id_contains_newlines?).to be_truthy end end - describe '#translations_contain_newlines'do + describe '#translations_contain_newlines' do it 'is true when the msgid is an array' do data = { msgstr: %w(hello world) } - entry = described_class.new(data) + entry = described_class.new(data, 2) expect(entry.translations_contain_newlines?).to be_truthy end From 538104bdd1f8f8905e2bc514bc5f94d564e3bbef Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 31 Aug 2017 19:00:29 +0200 Subject: [PATCH 15/16] Fetch all translation keys using a regex --- lib/gitlab/i18n/translation_entry.rb | 6 +----- spec/lib/gitlab/i18n/po_linter_spec.rb | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb index 75d5aa0cfe1..8d4fec0decd 100644 --- a/lib/gitlab/i18n/translation_entry.rb +++ b/lib/gitlab/i18n/translation_entry.rb @@ -67,11 +67,7 @@ module Gitlab private def translation_keys - @translation_keys ||= if has_plural? - entry_data.keys.select { |key| key =~ /msgstr\[\d+\]/ } - else - [:msgstr] - end + @translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ } end end end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index 97a8c105264..bd31f0c3871 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -106,7 +106,7 @@ describe Gitlab::I18n::PoLinter do context 'with multiple plurals' do let(:po_path) { 'spec/fixtures/multiple_plurals.po' } - it 'has no errors' do + it 'has errors' do is_expected.not_to be_empty end end From 4761235f6944d1627346ca835a192c1ed32f745e Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 31 Aug 2017 20:11:22 +0200 Subject: [PATCH 16/16] Validate unescaped `%` chars in PO files --- lib/gitlab/i18n/po_linter.rb | 21 +++++- lib/gitlab/i18n/translation_entry.rb | 18 +++++ spec/fixtures/unescaped_chars.po | 21 ++++++ spec/lib/gitlab/i18n/po_linter_spec.rb | 14 +++- .../lib/gitlab/i18n/translation_entry_spec.rb | 70 +++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 spec/fixtures/unescaped_chars.po diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb index c3b1fe582af..2e02787a4f4 100644 --- a/lib/gitlab/i18n/po_linter.rb +++ b/lib/gitlab/i18n/po_linter.rb @@ -63,10 +63,25 @@ module Gitlab validate_variables(errors, entry) validate_newlines(errors, entry) validate_number_of_plurals(errors, entry) + validate_unescaped_chars(errors, entry) errors end + def validate_unescaped_chars(errors, entry) + if entry.msgid_contains_unescaped_chars? + errors << 'contains unescaped `%`, escape it using `%%`' + end + + if entry.plural_id_contains_unescaped_chars? + errors << 'plural id contains unescaped `%`, escape it using `%%`' + end + + if entry.translations_contain_unescaped_chars? + errors << 'translation contains unescaped `%`, escape it using `%%`' + end + end + def validate_number_of_plurals(errors, entry) return unless metadata_entry&.expected_plurals return unless entry.translated? @@ -79,15 +94,15 @@ module Gitlab def validate_newlines(errors, entry) if entry.msgid_contains_newlines? - errors << "is defined over multiple lines, this breaks some tooling." + errors << 'is defined over multiple lines, this breaks some tooling.' end if entry.plural_id_contains_newlines? - errors << "plural is defined over multiple lines, this breaks some tooling." + errors << 'plural is defined over multiple lines, this breaks some tooling.' end if entry.translations_contain_newlines? - errors << "has translations defined over multiple lines, this breaks some tooling." + errors << 'has translations defined over multiple lines, this breaks some tooling.' end end diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb index 8d4fec0decd..e6c95afca7e 100644 --- a/lib/gitlab/i18n/translation_entry.rb +++ b/lib/gitlab/i18n/translation_entry.rb @@ -1,6 +1,8 @@ module Gitlab module I18n class TranslationEntry + PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze + attr_reader :nplurals, :entry_data def initialize(entry_data, nplurals) @@ -64,6 +66,22 @@ module Gitlab all_translations.any? { |translation| translation.is_a?(Array) } end + def msgid_contains_unescaped_chars? + contains_unescaped_chars?(msgid) + end + + def plural_id_contains_unescaped_chars? + contains_unescaped_chars?(plural_id) + end + + def translations_contain_unescaped_chars? + all_translations.any? { |translation| contains_unescaped_chars?(translation) } + end + + def contains_unescaped_chars?(string) + string =~ PERCENT_REGEX + end + private def translation_keys diff --git a/spec/fixtures/unescaped_chars.po b/spec/fixtures/unescaped_chars.po new file mode 100644 index 00000000000..fbafe523fb3 --- /dev/null +++ b/spec/fixtures/unescaped_chars.po @@ -0,0 +1,21 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR , 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"Last-Translator: Bob Van Landuyt \n" +"X-Generator: Poedit 2.0.2\n" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?" diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb index bd31f0c3871..cd5c2b99751 100644 --- a/spec/lib/gitlab/i18n/po_linter_spec.rb +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -110,6 +110,17 @@ describe Gitlab::I18n::PoLinter do is_expected.not_to be_empty end end + + context 'with unescaped chars' do + let(:po_path) { 'spec/fixtures/unescaped_chars.po' } + + it 'contains an error' do + message_id = 'You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?' + expected_error = 'translation contains unescaped `%`, escape it using `%%`' + + expect(errors[message_id]).to include(expected_error) + end + end end describe '#parse_po' do @@ -157,13 +168,14 @@ describe Gitlab::I18n::PoLinter do end describe '#validate_entry' do - it 'validates the flags, variable usage, and newlines' do + it 'validates the flags, variable usage, newlines, and unescaped chars' do fake_entry = double expect(linter).to receive(:validate_flags).with([], fake_entry) expect(linter).to receive(:validate_variables).with([], fake_entry) expect(linter).to receive(:validate_newlines).with([], fake_entry) expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry) + expect(linter).to receive(:validate_unescaped_chars).with([], fake_entry) linter.validate_entry(fake_entry) end diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb index d2d3ec03c6d..f68bc8feff9 100644 --- a/spec/lib/gitlab/i18n/translation_entry_spec.rb +++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb @@ -130,4 +130,74 @@ describe Gitlab::I18n::TranslationEntry do expect(entry.translations_contain_newlines?).to be_truthy end end + + describe '#contains_unescaped_chars' do + let(:data) { { msgid: '' } } + let(:entry) { described_class.new(data, 2) } + it 'is true when the msgid is an array' do + string = '「100%確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + + it 'is false when the `%` char is escaped' do + string = '「100%%確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is false when using an unnamed variable' do + string = '「100%d確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is false when using a named variable' do + string = '「100%{named}確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is true when an unnamed variable is not closed' do + string = '「100%{named確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + + it 'is true when the string starts with a `%`' do + string = '%10' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + end + + describe '#msgid_contains_unescaped_chars' do + it 'is true when the msgid contains a `%`' do + data = { msgid: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.msgid_contains_unescaped_chars?).to be_truthy + end + end + + describe '#plural_id_contains_unescaped_chars' do + it 'is true when the plural msgid contains a `%`' do + data = { msgid_plural: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.plural_id_contains_unescaped_chars?).to be_truthy + end + end + + describe '#translations_contain_unescaped_chars' do + it 'is true when the translation contains a `%`' do + data = { msgstr: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.translations_contain_unescaped_chars?).to be_truthy + end + end end