Skip to content

Commit 41c18b0

Browse files
authored
[Tooling] Add localization automation (#765)
* Use latest release-toolkit Gem * Add Fluent / PO localization lanes * Update source PO file (en-US.pot) to be synced to GlotPress * Update generated Fluent files based on latest GlotPress translations * Update release-toolkit * Fix Rubocop violations * Run bundle update * Create temporary dir for downloading PO files and use open-uri for downloading * Update code to use `fluent-tools` Gem * Update generated source PO file * Update generated Fluent files based on latest GlotPress translations * Reorder helper lane * Validate file_path at the beginning of the `generate_fluent_file_from_po` lane * Update code to exit early in `download_translations` when there are no downloaded files * Add all locales supported by the iOS and Android apps h/t to @crazytonyli for highlighting that the supported locales should be a combination of the locales supported in the iOS and Android apps #765 (comment) * Add generated Fluent files with the updated list of supported locales * Update fluent-tools to 0.2.0 and re-generate localization files with the latest version * Update to 0.3.0 release * Update PO file with latest fluent-tools
1 parent b52dfd0 commit 41c18b0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+691
-44
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ source 'https://rubygems.org'
44

55
gem 'fastlane', '~> 2.228'
66
gem 'fastlane-plugin-wpmreleasetoolkit', '~> 13.3'
7+
gem 'fluent-tools', '~> 0.3'

Gemfile.lock

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ GEM
153153
xcodeproj (~> 1.22)
154154
fastlane-sirp (1.0.0)
155155
sysrandom (~> 1.0)
156+
fluent-tools (0.3.0)
157+
thor (~> 1.0)
156158
gh_inspector (1.1.3)
157159
git (1.19.1)
158160
addressable (~> 2.8)
@@ -215,10 +217,22 @@ GEM
215217
nanaimo (0.4.0)
216218
naturally (2.3.0)
217219
nkf (0.2.0)
220+
nokogiri (1.18.8-aarch64-linux-gnu)
221+
racc (~> 1.4)
222+
nokogiri (1.18.8-aarch64-linux-musl)
223+
racc (~> 1.4)
224+
nokogiri (1.18.8-arm-linux-gnu)
225+
racc (~> 1.4)
226+
nokogiri (1.18.8-arm-linux-musl)
227+
racc (~> 1.4)
218228
nokogiri (1.18.8-arm64-darwin)
219229
racc (~> 1.4)
230+
nokogiri (1.18.8-x86_64-darwin)
231+
racc (~> 1.4)
220232
nokogiri (1.18.8-x86_64-linux-gnu)
221233
racc (~> 1.4)
234+
nokogiri (1.18.8-x86_64-linux-musl)
235+
racc (~> 1.4)
222236
octokit (6.1.1)
223237
faraday (>= 1, < 3)
224238
sawyer (~> 0.9)
@@ -262,6 +276,7 @@ GEM
262276
terminal-notifier (2.0.0)
263277
terminal-table (3.0.2)
264278
unicode-display_width (>= 1.1.1, < 3)
279+
thor (1.3.2)
265280
trailblazer-option (0.1.2)
266281
tty-cursor (0.7.1)
267282
tty-screen (0.8.2)
@@ -286,13 +301,19 @@ GEM
286301
xcpretty (~> 0.2, >= 0.0.7)
287302

288303
PLATFORMS
289-
arm64-darwin-22
290-
arm64-darwin-23
291-
x86_64-linux
304+
aarch64-linux-gnu
305+
aarch64-linux-musl
306+
arm-linux-gnu
307+
arm-linux-musl
308+
arm64-darwin
309+
x86_64-darwin
310+
x86_64-linux-gnu
311+
x86_64-linux-musl
292312

293313
DEPENDENCIES
294314
fastlane (~> 2.228)
295315
fastlane-plugin-wpmreleasetoolkit (~> 13.3)
316+
fluent-tools (~> 0.3)
296317

297318
BUNDLED WITH
298-
2.4.10
319+
2.6.3

fastlane/Fastfile

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require 'open-uri'
4+
require 'fluent_tools'
5+
36
PROJECT_ROOT = File.expand_path('..', __dir__)
47

58
LANE_VALUE_VERSION = 'WP_VERSION'
@@ -11,6 +14,72 @@ LANE_VALUE_XCFRAMEWORK_CHECKSUM_PATH = 'WP_XCFRAMEWORK_CHECKSUM_PATH'
1114
GITHUB_REPO = 'automattic/wordpress-rs'
1215
GIT_REMOTE_NAME = 'origin'
1316

17+
# Localization constants
18+
LOCALIZATION_FLUENT_FILES_DIR = File.join(PROJECT_ROOT, 'wp_localization', 'localization')
19+
LOCALIZATION_PO_SOURCE_FILE = File.join(PROJECT_ROOT, 'wp_localization', 'glotpress', 'en-US.pot')
20+
MAIN_FLUENT_FILE_NAME = 'main.ftl'
21+
PROJECT_NAME = 'wordpress-rs'
22+
23+
# GlotPress configuration
24+
GLOTPRESS_PROJECT_BASE_URL = 'https://translate.wordpress.com/projects/mobile/wordpress-rs'
25+
26+
# Supported locales mapping between GlotPress and project locale codes
27+
# This list combines locales supported in the iOS and Android apps
28+
SUPPORTED_LOCALES = [
29+
{ glotpress: 'ar', project: 'ar' },
30+
{ glotpress: 'az', project: 'az' },
31+
{ glotpress: 'bg', project: 'bg' },
32+
{ glotpress: 'cs', project: 'cs' },
33+
{ glotpress: 'cy', project: 'cy' },
34+
{ glotpress: 'da', project: 'da' },
35+
{ glotpress: 'de', project: 'de' },
36+
{ glotpress: 'el', project: 'el' },
37+
{ glotpress: 'en-au', project: 'en-AU' },
38+
{ glotpress: 'en-ca', project: 'en-CA' },
39+
{ glotpress: 'en-gb', project: 'en-GB' },
40+
{ glotpress: 'es', project: 'es' },
41+
{ glotpress: 'es-cl', project: 'es-CL' },
42+
{ glotpress: 'es-co', project: 'es-CO' },
43+
{ glotpress: 'es-mx', project: 'es-MX' },
44+
{ glotpress: 'es-ve', project: 'es-VE' },
45+
{ glotpress: 'eu', project: 'eu' },
46+
{ glotpress: 'fr', project: 'fr' },
47+
{ glotpress: 'fr-ca', project: 'fr-CA' },
48+
{ glotpress: 'gd', project: 'gd' },
49+
{ glotpress: 'gl', project: 'gl' },
50+
{ glotpress: 'he', project: 'he' },
51+
{ glotpress: 'hi', project: 'hi' },
52+
{ glotpress: 'hr', project: 'hr' },
53+
{ glotpress: 'hu', project: 'hu' },
54+
{ glotpress: 'id', project: 'id' },
55+
{ glotpress: 'is', project: 'is' },
56+
{ glotpress: 'it', project: 'it' },
57+
{ glotpress: 'ja', project: 'ja' },
58+
{ glotpress: 'kmr', project: 'kmr' },
59+
{ glotpress: 'ko', project: 'ko' },
60+
{ glotpress: 'lv', project: 'lv' },
61+
{ glotpress: 'mk', project: 'mk' },
62+
{ glotpress: 'ms', project: 'ms' },
63+
{ glotpress: 'nb', project: 'nb' },
64+
{ glotpress: 'nl', project: 'nl' },
65+
{ glotpress: 'pl', project: 'pl' },
66+
{ glotpress: 'pt', project: 'pt' },
67+
{ glotpress: 'pt-br', project: 'pt-BR' },
68+
{ glotpress: 'ro', project: 'ro' },
69+
{ glotpress: 'ru', project: 'ru' },
70+
{ glotpress: 'sk', project: 'sk' },
71+
{ glotpress: 'sq', project: 'sq' },
72+
{ glotpress: 'sr', project: 'sr' },
73+
{ glotpress: 'sv', project: 'sv' },
74+
{ glotpress: 'th', project: 'th' },
75+
{ glotpress: 'tr', project: 'tr-TR' },
76+
{ glotpress: 'uz', project: 'uz' },
77+
{ glotpress: 'vi', project: 'vi' },
78+
{ glotpress: 'zh-cn', project: 'zh-CN' },
79+
{ glotpress: 'zh-hk', project: 'zh-HK' },
80+
{ glotpress: 'zh-tw', project: 'zh-TW' }
81+
].freeze
82+
1483
lane :release do |options|
1584
version = options[:version] || UI.user_error!('version is required')
1685
lane_context[LANE_VALUE_VERSION] = version
@@ -105,6 +174,154 @@ lane :publish_to_s3 do
105174
)
106175
end
107176

177+
# Converts the English Fluent localization file to PO format for translation
178+
#
179+
# The resulting PO file is saved as the source file (.pot) for translations and is synced to GlotPress.
180+
#
181+
# @param commit_and_push_changes [Boolean] Whether to commit and push the generated PO file (default: false)
182+
#
183+
lane :generate_source_po_file do |commit_and_push_changes: false|
184+
UI.header('🔄 Converting English Fluent file to PO format')
185+
186+
FileUtils.mkdir_p(File.dirname(LOCALIZATION_PO_SOURCE_FILE))
187+
188+
fluent_file = File.join(LOCALIZATION_FLUENT_FILES_DIR, 'en-US', MAIN_FLUENT_FILE_NAME)
189+
190+
UI.user_error!("❌ English Fluent file not found: #{fluent_file}") unless File.exist?(fluent_file)
191+
192+
begin
193+
FluentTools.fluent_to_po(
194+
fluent_file,
195+
LOCALIZATION_PO_SOURCE_FILE,
196+
locale: 'en-US'
197+
)
198+
199+
UI.success("✅ #{File.basename(fluent_file)}#{File.basename(LOCALIZATION_PO_SOURCE_FILE)}")
200+
rescue StandardError => e
201+
UI.error("❌ Failed to convert English Fluent file: #{e.message}")
202+
end
203+
204+
if commit_and_push_changes
205+
commit_changed_files(
206+
files: LOCALIZATION_PO_SOURCE_FILE,
207+
message: 'Update source PO file (en-US.pot) to be synced to GlotPress'
208+
)
209+
end
210+
end
211+
212+
# Downloads the latest translations from GlotPress and updates Fluent files
213+
#
214+
# This lane fetches translated PO files from GlotPress for all supported locales,
215+
# converts them back to Fluent format, and optionally commits and pushes the changes.
216+
#
217+
# @param commit_and_push_changes [Boolean] Whether to commit and push the updated Fluent files (default: false)
218+
#
219+
lane :download_translations do |commit_and_push_changes: false|
220+
UI.header('🌐 Downloading translations from GlotPress')
221+
222+
Dir.mktmpdir do |temp_download_dir|
223+
downloaded_files = download_po_files_from_glotpress(download_dir: temp_download_dir)
224+
updated_fluent_files = []
225+
226+
if downloaded_files.empty?
227+
UI.message("No .po files were downloaded from GlotPress")
228+
next
229+
end
230+
231+
UI.header('🔄 Converting PO files to Fluent format')
232+
233+
downloaded_files.each do |file_path|
234+
fluent_file_path = generate_fluent_file_from_po(file_path: file_path)
235+
updated_fluent_files << fluent_file_path if fluent_file_path
236+
rescue StandardError => e
237+
UI.error("❌ Failed to convert #{File.basename(file_path)}: #{e.message}")
238+
end
239+
240+
UI.success("✅ Updated Fluent files: #{updated_fluent_files.length} locales")
241+
242+
if commit_and_push_changes && updated_fluent_files.any?
243+
commit_changed_files(
244+
files: updated_fluent_files,
245+
message: 'Update generated Fluent files based on latest GlotPress translations'
246+
)
247+
end
248+
end
249+
end
250+
251+
# Downloads PO files from GlotPress for existing project locales
252+
#
253+
# This lane fetches translated PO files from GlotPress for all supported locales
254+
# that exist in the project. It only downloads files for locales that have
255+
# directories in the source localization folder.
256+
#
257+
# @return [Array<String>] List of successfully downloaded files
258+
#
259+
lane :download_po_files_from_glotpress do |download_dir:|
260+
UI.header('🌐 Downloading PO files from GlotPress')
261+
262+
UI.user_error!("Download directory does not exist: #{download_dir}") unless Dir.exist?(download_dir)
263+
264+
downloaded_files = []
265+
266+
SUPPORTED_LOCALES.each do |locale_mapping|
267+
glotpress_locale = locale_mapping[:glotpress]
268+
project_locale = locale_mapping[:project]
269+
270+
po_file_path = File.join(download_dir, "#{project_locale}.po")
271+
download_url = "#{GLOTPRESS_PROJECT_BASE_URL}/#{glotpress_locale}/default/export-translations/?format=po"
272+
273+
UI.message("⬇️ Downloading PO file for #{project_locale}...")
274+
275+
begin
276+
# Download PO file using Ruby's URI.open
277+
File.write(po_file_path, URI.open(download_url).read)
278+
279+
if File.exist?(po_file_path) && !File.empty?(po_file_path)
280+
downloaded_files << po_file_path
281+
else
282+
UI.error("❌ Failed to download #{project_locale}: empty response")
283+
end
284+
rescue StandardError => e
285+
UI.error("❌ Failed to download #{project_locale}: #{e.message}")
286+
end
287+
end
288+
289+
UI.success("✅ Downloaded: #{downloaded_files.length} PO files") if downloaded_files.any?
290+
291+
downloaded_files
292+
end
293+
294+
# Converts a PO file for a given locale back to Fluent format
295+
#
296+
# This lane takes a PO file and converts it to the corresponding Fluent format file.
297+
# The locale is extracted from the PO filename (e.g., 'fr-FR.po' becomes 'fr-FR').
298+
# The resulting Fluent file is saved in the appropriate locale directory.
299+
#
300+
# @param file_path [String] The PO file path to convert (e.g., 'path/to/fr-FR.po')
301+
# @return [String] The path to the generated Fluent file
302+
#
303+
lane :generate_fluent_file_from_po do |file_path:|
304+
UI.user_error!("PO file not found: #{file_path}") unless File.exist?(file_path)
305+
306+
locale = File.basename(file_path, '.po')
307+
fluent_file_path = File.join(LOCALIZATION_FLUENT_FILES_DIR, locale, MAIN_FLUENT_FILE_NAME)
308+
309+
FileUtils.mkdir_p(File.dirname(fluent_file_path))
310+
311+
FluentTools.po_to_fluent(
312+
file_path,
313+
fluent_file_path
314+
)
315+
316+
next if !File.exist?(fluent_file_path) || File.empty?(fluent_file_path)
317+
318+
UI.message("✅ #{File.basename(file_path)}#{fluent_file_path}")
319+
320+
fluent_file_path
321+
end
322+
323+
# Utils
324+
108325
def xcframework_checksum
109326
File.read(xcframework_checksum_file_path).strip!
110327
end
@@ -126,3 +343,18 @@ end
126343
def remove_lane_context_values(names)
127344
names.each { |name| lane_context.delete(name) }
128345
end
346+
347+
def commit_changed_files(files:, message:, push: true)
348+
git_add(path: files)
349+
result = git_commit(
350+
path: files,
351+
message: message,
352+
allow_nothing_to_commit: true
353+
)
354+
355+
if result.nil?
356+
UI.important('⚠️ No changed files')
357+
elsif push
358+
push_to_git_remote(set_upstream: true, tags: false)
359+
end
360+
end

0 commit comments

Comments
 (0)