diff --git a/CHANGELOG.md b/CHANGELOG.md index 4690cb0ef..f238e2862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Added `android_prune_orphaned_translations` action: removes ``, `` and `` entries from `values-*/strings.xml` whose key is not declared in the source strings (the res dir's default `values/strings.xml`, optionally unioned with `additional_source_strings_paths`). Useful after downloading translations from GlotPress to avoid Lint `ExtraTranslation` errors from keys removed/renamed since the source was last synced. [#734] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb new file mode 100644 index 000000000..1e23f1404 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'nokogiri' + +module Fastlane + module Actions + class AndroidPruneOrphanedTranslationsAction < Action + # Matches an Android `values-` directory whose qualifier is a locale (a language code, optional + # region, or a BCP-47 `b+` form), so non-locale qualifier dirs (e.g. `values-night`, `values-v21`, + # `values-land`) are left untouched. + LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/ + + def self.run(params) + res_dir = params[:res_dir] + source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] + source_paths.each do |path| + UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path) + end + valid_keys = collect_keys(source_paths) + + locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')).select do |file| + File.basename(File.dirname(file)).match?(LOCALE_VALUES_DIR_REGEX) + end + total_pruned = 0 + + locale_files.each do |file| + pruned = prune_file(file: file, valid_keys: valid_keys) + next if pruned.empty? + + total_pruned += pruned.count + UI.message("Pruned #{pruned.count} orphaned entries from `#{file}`: #{pruned.join(', ')}") + end + + UI.success("Pruned #{total_pruned} orphaned translation entries across #{locale_files.count} locale file(s).") + total_pruned + end + + # Collects the set of resource names (string, string-array, plurals, …) declared in the given strings files. + # + # @param [Array] paths The strings.xml files to read the valid keys from. + # @return [Set] The set of declared resource names. + def self.collect_keys(paths) + paths.each_with_object(Set.new) do |path, keys| + doc = File.open(path) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) } + doc.xpath('/resources/*[@name]').each { |node| keys << node['name'] } + end + end + + # Removes from `file` any resource entry whose `name` is not in `valid_keys`, preserving the rest of the + # file's formatting (so the change shows up as a minimal diff). + # + # @param [String] file The locale strings.xml file to prune. + # @param [Set] valid_keys The set of names that are allowed to remain. + # @return [Array] The names of the entries that were pruned. + def self.prune_file(file:, valid_keys:) + doc = File.open(file) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) } + orphans = doc.xpath('/resources/*[@name]').reject { |node| valid_keys.include?(node['name']) } + return [] if orphans.empty? + + names = orphans.map { |node| node['name'] } + orphans.each do |node| + # Drop the indentation/newline text node right before the element too, to avoid leaving a blank line. + previous = node.previous_sibling + previous.remove if previous&.text? && previous.text.strip.empty? + node.remove + end + + File.open(file, 'w') { |f| doc.write_to(f, encoding: Encoding::UTF_8.to_s, indent: 4) } + names + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Removes translations whose key is not present in the source strings, to avoid Lint `ExtraTranslation` errors' + end + + def self.details + <<~DETAILS + When downloading translations from GlotPress, the export may include keys that are no longer present in + the app's source strings (e.g. removed or renamed since the GlotPress source was last synced). Android + Lint flags these orphaned translations as `ExtraTranslation` errors. + + This action removes — from every `values-*/strings.xml` under `res_dir` — any ``, `` + or `` entry whose `name` is not declared in the default `values/strings.xml` of `res_dir`, + optionally unioned with `additional_source_strings_paths` (useful when a product flavor overlays a base + module's resources at build time, so the base module's keys are valid too). + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :res_dir, + description: "Path to the Android project's `res` directory containing the `values-*` locale subdirectories to prune", + type: String, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :additional_source_strings_paths, + description: 'Paths to additional default `strings.xml` files whose keys should also be treated as valid ' \ + '(e.g. a base module that the pruned `res_dir` overlays at build time)', + type: Array, + optional: true, + default_value: [] + ), + ] + end + + def self.return_value + 'The total number of orphaned translation entries that were pruned' + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + platform == :android + end + end + end +end diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb new file mode 100644 index 000000000..e51de2b21 --- /dev/null +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +describe Fastlane::Actions::AndroidPruneOrphanedTranslationsAction do + # Writes `content` to `path`, creating intermediate directories. + def write_file(path, content) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + + # A default `values/strings.xml` declaring `hello`, `bye`, an array and a plural. + let(:default_strings) do + <<~XML + + + Hello + Bye + + Earth + + + %d item + %d items + + + XML + end + + it 'removes only the entries whose key is not in the default strings, keeping the rest intact' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Bonjour + Orphelin + Au revoir + + %d truc + %d trucs + + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(2) + content = File.read(fr_file) + expect(content).to include('name="hello"', 'name="bye"') + expect(content).not_to include('orphan_string', 'orphan_plural') + # No blank line left behind where the orphaned was removed. + expect(content).not_to match(/\n[[:space:]]*\n[[:space:]]* + + Night + + XML + write_file(night_file, night_content) + # A real locale dir with an orphan, to confirm pruning still happens there. + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Bonjour + Orphelin + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(1) + expect(File.read(night_file)).to eq(night_content) + expect(File.read(fr_file)).not_to include('orphan_string') + end + end + + it 'treats keys from `additional_source_strings_paths` as valid (flavor overlay case)' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), <<~XML) + + + Flavor + + XML + base_strings = File.join(dir, 'base', 'values', 'strings.xml') + write_file(base_strings, default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + write_file(fr_file, <<~XML) + + + Saveur + Bonjour + Orphelin + + XML + + pruned = run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [base_strings]) + + expect(pruned).to eq(1) + content = File.read(fr_file) + expect(content).to include('name="flavor_only"', 'name="hello"') + expect(content).not_to include('orphan_string') + end + end + + it 'does nothing and reports zero when there are no orphaned entries' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + fr_file = File.join(res_dir, 'values-fr', 'strings.xml') + fr_content = <<~XML + + + Bonjour + Au revoir + + XML + write_file(fr_file, fr_content) + + pruned = run_described_fastlane_action(res_dir: res_dir) + + expect(pruned).to eq(0) + expect(File.read(fr_file)).to eq(fr_content) + end + end + + it 'raises a clear error when the res dir has no default strings file' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + FileUtils.mkdir_p(res_dir) + expect do + run_described_fastlane_action(res_dir: res_dir) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/) + end + end + + it 'raises a clear error when an additional source strings path is missing' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + expect do + run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [File.join(dir, 'missing.xml')]) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/) + end + end +end