From ef04613f4809dbc801ea418a7ee88b0926a6dffe Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Wed, 17 Jun 2026 16:54:52 -0400 Subject: [PATCH 1/5] Add android_prune_orphaned_translations action Removes // 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 synced. --- CHANGELOG.md | 2 +- ...roid_prune_orphaned_translations_action.rb | 116 ++++++++++++++++++ ...ndroid_prune_orphaned_translations_spec.rb | 110 +++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb create mode 100644 spec/android_prune_orphaned_translations_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4690cb0ef..f38e80a4e 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. ### 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..ea905b9e4 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_prune_orphaned_translations_action.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'fastlane/action' +require 'nokogiri' + +module Fastlane + module Actions + class AndroidPruneOrphanedTranslationsAction < Action + def self.run(params) + res_dir = params[:res_dir] + source_paths = [File.join(res_dir, 'values', 'strings.xml')] + params[:additional_source_strings_paths] + valid_keys = collect_keys(source_paths) + + locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')) + 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..1ab659c8b --- /dev/null +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +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:]]* + + 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 +end From e1fbe30c1d11776bc53a52c731022ba1b153f3a1 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 09:13:42 -0400 Subject: [PATCH 2/5] Address Copilot review on android_prune_orphaned_translations Add explicit `require 'fileutils'` to the spec (matches existing specs and is robust if spec_helper's transitive load changes). Declined the suggested `require 'set'` in the action: Set is autoloaded on Ruby >= 3.2 (the gem's floor) and rubocop's Lint/RedundantRequireStatement would reject it. --- spec/android_prune_orphaned_translations_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 1ab659c8b..727c04e57 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' require 'tmpdir' +require 'fileutils' describe Fastlane::Actions::AndroidPruneOrphanedTranslationsAction do # Writes `content` to `path`, creating intermediate directories. From efb03ccbadbb7974227ba15ba00320de0a86d740 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 09:39:04 -0400 Subject: [PATCH 3/5] Only prune locale qualifier dirs, not values-night/v21/etc. Restrict the glob to Android locale-qualifier directories (language, optional region, or BCP-47 b+ form) so non-locale qualifier dirs like values-night or values-v21 are left untouched. Addresses Copilot review on #734. --- ...roid_prune_orphaned_translations_action.rb | 9 +++++- ...ndroid_prune_orphaned_translations_spec.rb | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) 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 index ea905b9e4..ed82be875 100644 --- 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 @@ -6,12 +6,19 @@ 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] valid_keys = collect_keys(source_paths) - locale_files = Dir.glob(File.join(res_dir, 'values-*', 'strings.xml')) + 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| diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 727c04e57..224bd573a 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -58,6 +58,37 @@ def write_file(path, content) end end + it 'leaves non-locale qualifier directories (e.g. values-night) untouched' do + Dir.mktmpdir do |dir| + res_dir = File.join(dir, 'res') + write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings) + # A non-locale qualifier dir with a key absent from the default must NOT be pruned. + night_file = File.join(res_dir, 'values-night', 'strings.xml') + night_content = <<~XML + + + 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') From e7789a45bcb67e19c12866e4ad6d6ad00dbfe3bb Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 10:12:39 -0400 Subject: [PATCH 4/5] Validate source strings paths with a clear error Fail with a user-facing message when the res dir's default values/strings.xml or any additional_source_strings_paths entry is missing, instead of a low-level Errno::ENOENT. Addresses Copilot review on #734. --- ...roid_prune_orphaned_translations_action.rb | 3 +++ ...ndroid_prune_orphaned_translations_spec.rb | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+) 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 index ed82be875..1e23f1404 100644 --- 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 @@ -14,6 +14,9 @@ class AndroidPruneOrphanedTranslationsAction < Action 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| diff --git a/spec/android_prune_orphaned_translations_spec.rb b/spec/android_prune_orphaned_translations_spec.rb index 224bd573a..e51de2b21 100644 --- a/spec/android_prune_orphaned_translations_spec.rb +++ b/spec/android_prune_orphaned_translations_spec.rb @@ -139,4 +139,24 @@ def write_file(path, content) 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 From b6ac1db8d0f6bbb310e35ef607ac28b04ad6bcd6 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Thu, 18 Jun 2026 10:21:26 -0400 Subject: [PATCH 5/5] Add PR reference to the CHANGELOG entry [#734] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f38e80a4e..f238e2862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -- 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. +- 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