Skip to content

Commit 28272d7

Browse files
authored
Add android_prune_orphaned_translations action (#734)
2 parents 88bfb27 + f6c78ff commit 28272d7

3 files changed

Lines changed: 351 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
- `android_prune_orphaned_translations` action: removes `<string>`, `<string-array>` and `<plurals>` entries from `values-*/strings.xml` whose keys are not declared in the source `values/strings.xml` optionally unioned with `additional_source_strings_paths`. [#734]
1414

1515
### Bug Fixes
1616

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
require 'fastlane/action'
4+
require 'nokogiri'
5+
6+
module Fastlane
7+
module Actions
8+
class AndroidPruneOrphanedTranslationsAction < Action
9+
# Matches an Android `values-<qualifier>` directory whose qualifier is a locale: a 2- or 3-letter language
10+
# code (`fr`, `kmr`), an optional region (`pt-rBR`, `es-r419`), or a BCP-47 `b+` form (`b+sr+Latn`), so
11+
# non-locale qualifier dirs (e.g. `values-night`, `values-v21`, `values-land`) are left untouched.
12+
LOCALE_VALUES_DIR_REGEX = /\Avalues-(?:b\+[a-zA-Z]+(?:\+[a-zA-Z0-9]+)*|[a-z]{2,3}(?:-r(?:[A-Z]{2}|\d{3}))?)\z/
13+
14+
# Android UI-mode qualifiers, which are never locales:
15+
# https://developer.android.com/guide/topics/resources/providing-resources#UiModeQualifier
16+
# `car` is indistinguishable by shape from a 3-letter ISO 639 language code, so `LOCALE_VALUES_DIR_REGEX`
17+
# would otherwise treat `values-car` as a locale and prune its (valid) car-mode resources. There's no purely
18+
# syntactic way to tell the two apart, so we exclude the documented UI-mode qualifiers explicitly. (`car` is
19+
# the only one short enough to currently match the regex; the rest are listed to capture the full set.)
20+
UI_MODE_QUALIFIERS = %w[car desk television appliance watch vrheadset].freeze
21+
22+
DEFAULT_SOURCE_STRINGS_FILE_NAME = 'strings.xml'
23+
DEFAULT_SOURCE_STRINGS_RELATIVE_PATH = File.join('values', DEFAULT_SOURCE_STRINGS_FILE_NAME).freeze
24+
25+
def self.run(params)
26+
res_dir = params[:res_dir]
27+
source_paths = [File.join(res_dir, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH)] + params[:additional_source_strings_paths]
28+
valid_keys = collect_keys(source_paths)
29+
30+
locale_files = Dir.glob(File.join(res_dir, 'values-*', DEFAULT_SOURCE_STRINGS_FILE_NAME)).select do |file|
31+
dir_name = File.basename(File.dirname(file))
32+
dir_name.match?(LOCALE_VALUES_DIR_REGEX) && !UI_MODE_QUALIFIERS.include?(dir_name.delete_prefix('values-'))
33+
end
34+
total_pruned = 0
35+
36+
locale_files.each do |file|
37+
pruned = prune_file(file: file, valid_keys: valid_keys)
38+
next if pruned.empty?
39+
40+
total_pruned += pruned.count
41+
UI.message("Pruned #{pruned.count} orphaned entries from `#{file}`: #{pruned.join(', ')}")
42+
end
43+
44+
UI.success("Pruned #{total_pruned} orphaned translation entries across #{locale_files.count} locale file(s).")
45+
total_pruned
46+
end
47+
48+
# Collects the set of resource names (string, string-array, plurals, …) declared in the given strings files.
49+
#
50+
# @param [Array<String>] paths The strings.xml files to read the valid keys from.
51+
# @return [Set<String>] The set of declared resource names.
52+
def self.collect_keys(paths)
53+
paths.each_with_object(Set.new) do |path, keys|
54+
doc = File.open(path) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
55+
doc.xpath('/resources/*[@name]').each { |node| keys << node['name'] }
56+
end
57+
end
58+
59+
# Removes from `file` any resource entry whose `name` is not in `valid_keys`, preserving the rest of the
60+
# file's formatting (so the change shows up as a minimal diff).
61+
#
62+
# @param [String] file The locale strings.xml file to prune.
63+
# @param [Set<String>] valid_keys The set of names that are allowed to remain.
64+
# @return [Array<String>] The names of the entries that were pruned.
65+
def self.prune_file(file:, valid_keys:)
66+
doc = File.open(file) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
67+
orphans = doc.xpath('/resources/*[@name]').reject { |node| valid_keys.include?(node['name']) }
68+
return [] if orphans.empty?
69+
70+
names = orphans.map { |node| node['name'] }
71+
orphans.each do |node|
72+
# Drop the indentation/newline text node right before the element too, to avoid leaving a blank line.
73+
previous = node.previous_sibling
74+
previous.remove if previous&.text? && previous.text.strip.empty?
75+
node.remove
76+
end
77+
78+
File.open(file, 'w') { |f| doc.write_to(f, encoding: Encoding::UTF_8.to_s, indent: 4) }
79+
names
80+
end
81+
82+
#####################################################
83+
# @!group Documentation
84+
#####################################################
85+
86+
def self.description
87+
'Removes translations whose keys are not present in the source strings, to avoid Lint `ExtraTranslation` errors'
88+
end
89+
90+
def self.details
91+
<<~DETAILS
92+
When downloading translations from GlotPress, the export may include keys that are no longer present in
93+
the app's source strings (e.g. removed or renamed since the GlotPress source was last synced). Android
94+
Lint flags these orphaned translations as `ExtraTranslation` errors.
95+
96+
This action removes — from every `values-*/strings.xml` under `res_dir` — any `<string>`, `<string-array>`
97+
or `<plurals>` entry whose `name` is not declared in the default `values/strings.xml` of `res_dir`,
98+
optionally unioned with `additional_source_strings_paths` (useful when a product flavor overlays a base
99+
module's resources at build time, so the base module's keys are valid too).
100+
DETAILS
101+
end
102+
103+
def self.available_options
104+
[
105+
FastlaneCore::ConfigItem.new(
106+
key: :res_dir,
107+
description: "Path to the Android project's `res` directory containing the `values-*` locale subdirectories to prune",
108+
type: String,
109+
optional: false,
110+
verify_block: proc do |value|
111+
source_path = File.join(value, DEFAULT_SOURCE_STRINGS_RELATIVE_PATH)
112+
UI.user_error!("Source strings file not found: `#{source_path}`") unless File.file?(source_path)
113+
end
114+
),
115+
FastlaneCore::ConfigItem.new(
116+
key: :additional_source_strings_paths,
117+
description: 'Paths to additional default `strings.xml` files whose keys should also be treated as valid ' \
118+
'(e.g. a base module that the pruned `res_dir` overlays at build time)',
119+
type: Array,
120+
optional: true,
121+
default_value: [],
122+
verify_block: proc do |value|
123+
value.each do |path|
124+
UI.user_error!("Source strings file not found: `#{path}`") unless File.file?(path)
125+
end
126+
end
127+
),
128+
]
129+
end
130+
131+
def self.return_value
132+
'The total number of orphaned translation entries that were pruned'
133+
end
134+
135+
def self.authors
136+
['Automattic']
137+
end
138+
139+
def self.is_supported?(platform)
140+
platform == :android
141+
end
142+
end
143+
end
144+
end
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'tmpdir'
5+
require 'fileutils'
6+
7+
describe Fastlane::Actions::AndroidPruneOrphanedTranslationsAction do
8+
# Writes `content` to `path`, creating intermediate directories.
9+
def write_file(path, content)
10+
FileUtils.mkdir_p(File.dirname(path))
11+
File.write(path, content)
12+
end
13+
14+
# A default `values/strings.xml` declaring `hello`, `bye`, an array and a plural.
15+
let(:default_strings) do
16+
<<~XML
17+
<?xml version="1.0" encoding="UTF-8"?>
18+
<resources>
19+
<string name="hello">Hello</string>
20+
<string name="bye">Bye</string>
21+
<string-array name="planets">
22+
<item>Earth</item>
23+
</string-array>
24+
<plurals name="items">
25+
<item quantity="one">%d item</item>
26+
<item quantity="other">%d items</item>
27+
</plurals>
28+
</resources>
29+
XML
30+
end
31+
32+
it 'removes only the entries whose key is not in the default strings, keeping the rest intact' do
33+
Dir.mktmpdir do |dir|
34+
res_dir = File.join(dir, 'res')
35+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
36+
fr_file = File.join(res_dir, 'values-fr', 'strings.xml')
37+
write_file(fr_file, <<~XML)
38+
<?xml version="1.0" encoding="UTF-8"?>
39+
<resources>
40+
<string name="hello">Bonjour</string>
41+
<string name="orphan_string">Orphelin</string>
42+
<string name="bye">Au revoir</string>
43+
<plurals name="orphan_plural">
44+
<item quantity="one">%d truc</item>
45+
<item quantity="other">%d trucs</item>
46+
</plurals>
47+
</resources>
48+
XML
49+
50+
pruned = run_described_fastlane_action(res_dir: res_dir)
51+
52+
expect(pruned).to eq(2)
53+
content = File.read(fr_file)
54+
expect(content).to include('name="hello"', 'name="bye"')
55+
expect(content).not_to include('orphan_string', 'orphan_plural')
56+
# No blank line left behind where the orphaned <string> was removed.
57+
expect(content).not_to match(/\n[[:space:]]*\n[[:space:]]*<string name="bye"/)
58+
end
59+
end
60+
61+
it 'leaves non-locale qualifier directories (e.g. values-night) untouched' do
62+
Dir.mktmpdir do |dir|
63+
res_dir = File.join(dir, 'res')
64+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
65+
# A non-locale qualifier dir with a key absent from the default must NOT be pruned.
66+
night_file = File.join(res_dir, 'values-night', 'strings.xml')
67+
night_content = <<~XML
68+
<?xml version="1.0" encoding="UTF-8"?>
69+
<resources>
70+
<string name="night_only">Night</string>
71+
</resources>
72+
XML
73+
write_file(night_file, night_content)
74+
# A real locale dir with an orphan, to confirm pruning still happens there.
75+
fr_file = File.join(res_dir, 'values-fr', 'strings.xml')
76+
write_file(fr_file, <<~XML)
77+
<?xml version="1.0" encoding="UTF-8"?>
78+
<resources>
79+
<string name="hello">Bonjour</string>
80+
<string name="orphan_string">Orphelin</string>
81+
</resources>
82+
XML
83+
84+
pruned = run_described_fastlane_action(res_dir: res_dir)
85+
86+
expect(pruned).to eq(1)
87+
expect(File.read(night_file)).to eq(night_content)
88+
expect(File.read(fr_file)).not_to include('orphan_string')
89+
end
90+
end
91+
92+
it 'leaves car UI mode qualifier directories untouched' do
93+
Dir.mktmpdir do |dir|
94+
res_dir = File.join(dir, 'res')
95+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
96+
car_file = File.join(res_dir, 'values-car', 'strings.xml')
97+
car_content = <<~XML
98+
<?xml version="1.0" encoding="UTF-8"?>
99+
<resources>
100+
<string name="car_only">Car</string>
101+
</resources>
102+
XML
103+
write_file(car_file, car_content)
104+
105+
pruned = run_described_fastlane_action(res_dir: res_dir)
106+
107+
expect(pruned).to eq(0)
108+
expect(File.read(car_file)).to eq(car_content)
109+
end
110+
end
111+
112+
# `kmr` (Northern Kurdish) is a real 3-letter legacy locale used by e.g. WordPress-Android, so it must still be
113+
# pruned — guarding against an over-eager "restrict locales to 2 letters" fix for the `values-car` collision.
114+
it 'prunes 3-letter legacy locale directories (e.g. values-kmr)' do
115+
Dir.mktmpdir do |dir|
116+
res_dir = File.join(dir, 'res')
117+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
118+
kmr_file = File.join(res_dir, 'values-kmr', 'strings.xml')
119+
write_file(kmr_file, <<~XML)
120+
<?xml version="1.0" encoding="UTF-8"?>
121+
<resources>
122+
<string name="hello">Silav</string>
123+
<string name="orphan_string">Sêwî</string>
124+
</resources>
125+
XML
126+
127+
pruned = run_described_fastlane_action(res_dir: res_dir)
128+
129+
expect(pruned).to eq(1)
130+
content = File.read(kmr_file)
131+
expect(content).to include('name="hello"')
132+
expect(content).not_to include('orphan_string')
133+
end
134+
end
135+
136+
it 'treats keys from `additional_source_strings_paths` as valid (flavor overlay case)' do
137+
Dir.mktmpdir do |dir|
138+
res_dir = File.join(dir, 'res')
139+
write_file(File.join(res_dir, 'values', 'strings.xml'), <<~XML)
140+
<?xml version="1.0" encoding="UTF-8"?>
141+
<resources>
142+
<string name="flavor_only">Flavor</string>
143+
</resources>
144+
XML
145+
base_strings = File.join(dir, 'base', 'values', 'strings.xml')
146+
write_file(base_strings, default_strings)
147+
fr_file = File.join(res_dir, 'values-fr', 'strings.xml')
148+
write_file(fr_file, <<~XML)
149+
<?xml version="1.0" encoding="UTF-8"?>
150+
<resources>
151+
<string name="flavor_only">Saveur</string>
152+
<string name="hello">Bonjour</string>
153+
<string name="orphan_string">Orphelin</string>
154+
</resources>
155+
XML
156+
157+
pruned = run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [base_strings])
158+
159+
expect(pruned).to eq(1)
160+
content = File.read(fr_file)
161+
expect(content).to include('name="flavor_only"', 'name="hello"')
162+
expect(content).not_to include('orphan_string')
163+
end
164+
end
165+
166+
it 'does nothing and reports zero when there are no orphaned entries' do
167+
Dir.mktmpdir do |dir|
168+
res_dir = File.join(dir, 'res')
169+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
170+
fr_file = File.join(res_dir, 'values-fr', 'strings.xml')
171+
fr_content = <<~XML
172+
<?xml version="1.0" encoding="UTF-8"?>
173+
<resources>
174+
<string name="hello">Bonjour</string>
175+
<string name="bye">Au revoir</string>
176+
</resources>
177+
XML
178+
write_file(fr_file, fr_content)
179+
180+
pruned = run_described_fastlane_action(res_dir: res_dir)
181+
182+
expect(pruned).to eq(0)
183+
expect(File.read(fr_file)).to eq(fr_content)
184+
end
185+
end
186+
187+
it 'raises a clear error when the res dir has no default strings file' do
188+
Dir.mktmpdir do |dir|
189+
res_dir = File.join(dir, 'res')
190+
FileUtils.mkdir_p(res_dir)
191+
expect do
192+
run_described_fastlane_action(res_dir: res_dir)
193+
end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/)
194+
end
195+
end
196+
197+
it 'raises a clear error when an additional source strings path is missing' do
198+
Dir.mktmpdir do |dir|
199+
res_dir = File.join(dir, 'res')
200+
write_file(File.join(res_dir, 'values', 'strings.xml'), default_strings)
201+
expect do
202+
run_described_fastlane_action(res_dir: res_dir, additional_source_strings_paths: [File.join(dir, 'missing.xml')])
203+
end.to raise_error(FastlaneCore::Interface::FastlaneError, /Source strings file not found/)
204+
end
205+
end
206+
end

0 commit comments

Comments
 (0)