Skip to content

Commit 1090ab6

Browse files
authored
Adding Crowdin integration to l10n scripts. (uplift to 1.74.x) (#27102)
Uplift of #27005 (squashed) to beta
1 parent f231794 commit 1090ab6

File tree

11 files changed

+1259
-45
lines changed

11 files changed

+1259
-45
lines changed

build/commands/lib/pullL10n.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ const pullL10n = (options) => {
2121

2222
l10nUtil.getBraveTopLevelPaths().forEach((sourceStringPath) => {
2323
if (!options.grd_path || sourceStringPath.endsWith(path.sep + options.grd_path)) {
24-
let cmd_args = ['script/pull-l10n.py', '--source_string_path', sourceStringPath]
24+
let args = ['script/pull-l10n.py',
25+
'--service', options.service,
26+
'--channel', options.channel,
27+
'--source_string_path', sourceStringPath]
2528
if (options.debug)
26-
cmd_args.push('--debug')
27-
util.run('python3', cmd_args, cmdOptions)
29+
args.push('--debug')
30+
util.run('python3', args, cmdOptions)
2831
}
2932
})
3033
}

build/commands/lib/pushL10n.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const pushL10n = (options) => {
3131
'python3',
3232
[
3333
'script/push-l10n.py',
34+
'--service', options.service,
35+
'--channel', options.channel,
3436
'--source_string_path',
3537
sourceStringPath,
3638
extraScriptOptions

build/commands/scripts/commands.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,19 @@ program
188188

189189
program
190190
.command('pull_l10n')
191+
.option('--service <service>', 'Service to use: Transifex or Crowdin')
192+
.option('--channel <channel>', 'Release|Beta|Nightly for Crowdin, Release for Transifex')
191193
.option('--grd_path <grd_path>', `Relative path to match end of full GRD path, e.g: 'generated_resources.grd'.`)
192-
.option('--debug', `Dumps downloaded content for one language into TransifexCurrent.txt file in the temp directory.`)
194+
.option('--debug', `Dumps downloaded content for one language into TransifexCurrent.txt or CrowdinCurrent.txt file in the temp directory.`)
193195
.action(pullL10n)
194196

195197
program
196198
.command('push_l10n')
199+
.option('--service <service>', 'Service to use: Transifex or Crowdin')
200+
.option('--channel <channel>', 'Release|Beta|Nightly for Crowdin, Release for Transifex')
197201
.option('--grd_path <grd_path>', `Relative path to match end of full GRD path, e.g: 'generated_resources.grd'.`)
198-
.option('--with_translations', 'Push local translations. WARNING: this will overwrite translations in Tansifex.')
199-
.option('--with_missing_translations', 'Push local translations for strings that do not have translations in Transifex.')
202+
.option('--with_translations', 'Push local translations. WARNING: this will overwrite translations in Transifex/Crowdin.')
203+
.option('--with_missing_translations', 'Push local translations for strings that do not have translations in Transifex/Crowdin.')
200204
.action(pushL10n)
201205

202206
program

script/lib/l10n/crowdin/__init__.py

Whitespace-only changes.
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright (c) 2024 The Brave Authors. All rights reserved.
4+
# This Source Code Form is subject to the terms of the Mozilla Public
5+
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
6+
# You can obtain one at https://mozilla.org/MPL/2.0/. */
7+
8+
import requests
9+
10+
from lib.config import get_env_var
11+
# pylint: disable=import-error
12+
from crowdin_api import CrowdinClient
13+
from crowdin_api.api_resources.source_files.enums import FileType
14+
# pylint: enable=import-error
15+
16+
# This module is a wrapper around Crowdin API v2
17+
18+
19+
class CrowdinClientWrapper():
20+
"""Wrapper class for the Crowdin API (v2) python SDK from
21+
https://github.com/crowdin/crowdin-api-client-python"""
22+
23+
def __init__(self, project_id):
24+
self._organization = 'Brave-Software'
25+
self._project_id = project_id
26+
self._auth_token = get_env_var('CROWDIN_API_KEY')
27+
assert self._project_id, \
28+
'CrowdinClientWrapper: project_id is not set.'
29+
assert self._auth_token, \
30+
'BRAVE_CROWDIN_API_KEY environmental var is not set.'
31+
# Set up CrowdinClient using an API token. You can generate one at
32+
# https://brave-software.crowdin.com/u/user_settings/access-tokens
33+
self._client = CrowdinClient(organization=self._organization,
34+
project_id=self.project_id,
35+
token=self._auth_token)
36+
37+
@property
38+
def project_id(self):
39+
return self._project_id
40+
41+
def __get_branch(self, branch_name):
42+
all_branches = self._client.source_files.list_project_branches(
43+
projectId=self._project_id)['data']
44+
for branch_data in all_branches:
45+
branch = branch_data['data']
46+
if branch['name'] == branch_name:
47+
return branch['id']
48+
return 0
49+
50+
def __create_branch(self, branch_name):
51+
branch = self._client.source_files.add_branch(
52+
name=branch_name, projectId=self._project_id)
53+
return branch['data']['id']
54+
55+
def __create_storage(self, resource_path):
56+
storage_data = self._client.storages.add_storage(
57+
open(resource_path, 'rb'))
58+
return storage_data['data']['id']
59+
60+
def __get_resource_file(self, branch_id, resource_name):
61+
all_files = self._client.source_files.list_files(
62+
projectId=self._project_id, branchId=branch_id)['data']
63+
for file_data in all_files:
64+
file = file_data['data']
65+
if file['name'] == resource_name:
66+
return file['id']
67+
return 0
68+
69+
def __add_resource_file(self, branch_id, storage_id, resource_name,
70+
file_type):
71+
file_types_map = {
72+
'ANDROID': FileType.ANDROID,
73+
'CHROME': FileType.CHROME
74+
}
75+
assert file_type in file_types_map, ('Unexpected file type: ' +
76+
f'{file_type}.')
77+
78+
new_file = self._client.source_files.add_file(
79+
storageId=storage_id,
80+
name=resource_name,
81+
projectId=self._project_id,
82+
branchId=branch_id,
83+
type=file_types_map[file_type])
84+
return new_file['data']['id']
85+
86+
def __update_resource_file(self, file_id, storage_id):
87+
updated_file = self._client.source_files.update_file(
88+
file_id, storageId=storage_id, projectId=self._project_id)
89+
return updated_file['data']['id']
90+
91+
def __get_resource_download_url(self, file_id):
92+
download = self._client.source_files.download_file(
93+
fileId=file_id, projectId=self._project_id)
94+
return download['data']['url']
95+
96+
def __get_resource_translation_download_url(self, file_id, lang_code):
97+
download = self._client.translations.export_project_translation(
98+
targetLanguageId=lang_code,
99+
projectId=self._project_id,
100+
fileIds=[file_id],
101+
skipUntranslatedStrings=True)
102+
return download['data']['url']
103+
104+
def __get_resource_file_strings(self, file_id):
105+
return \
106+
self._client.source_strings.with_fetch_all().list_strings(
107+
projectId=self._project_id, fileId=file_id)['data']
108+
109+
def __get_string_id_from_key(self, all_strings, string_key):
110+
for string_data in all_strings:
111+
string = string_data['data']
112+
if string['identifier'] == string_key:
113+
return string['id']
114+
return 0
115+
116+
def __has_source_string_l10n(self, string_id, lang_code):
117+
all_translations = \
118+
self._client.string_translations.list_string_translations(
119+
projectId=self._project_id, stringId=string_id,
120+
languageId=lang_code)['data']
121+
return len(all_translations) and \
122+
len(all_translations[0]['data']['text'])
123+
124+
def __delete_source_string_l10n(self, string_id, lang_code):
125+
self._client.string_translations.delete_string_translations(
126+
projectId=self._project_id,
127+
stringId=string_id,
128+
languageId=lang_code)
129+
130+
def __add_source_string_l10n(self, string_id, lang_code, translation):
131+
self._client.string_translations.add_translation(
132+
projectId=self._project_id,
133+
stringId=string_id,
134+
languageId=lang_code,
135+
text=translation)
136+
137+
def __upload_translation(self, file_id, storage_id, lang_code):
138+
uploaded_file = self._client.translations.upload_translation(
139+
projectId=self._project_id,
140+
languageId=lang_code,
141+
storageId=storage_id,
142+
fileId=file_id,
143+
importEqSuggestions=True, # Add l10n == source
144+
autoApproveImported=True,
145+
translateHidden=True)
146+
return uploaded_file['data']['fileId']
147+
148+
# Wrapper API
149+
150+
def is_supported_language(self, lang_code):
151+
project = self._client.projects.get_project(
152+
projectId=self._project_id)['data']
153+
return lang_code in project['targetLanguageIds']
154+
155+
def upload_resource_file(self, branch, upload_file_path, resource_name,
156+
i18n_type):
157+
"""Upload resource file to Crowdin"""
158+
# Create new storage for the file
159+
storage_id = self.__create_storage(upload_file_path)
160+
# Check if the branch already exists
161+
branch_id = self.__get_branch(branch)
162+
if branch_id:
163+
print(f'Branch {branch} already exists')
164+
# Check if this file already exists and if so update it
165+
file_id = self.__get_resource_file(branch_id, resource_name)
166+
if file_id:
167+
print(f'Resource {resource_name} already exists. Updating...')
168+
return self.__update_resource_file(file_id, storage_id)
169+
else:
170+
# Create new branch
171+
print(f'Creating new branch {branch}')
172+
branch_id = self.__create_branch(branch)
173+
174+
print(f'Creating a new resource {resource_name}')
175+
file_id = self.__add_resource_file(branch_id, storage_id,
176+
resource_name, i18n_type)
177+
return file_id
178+
179+
def get_resource_source(self, branch, resource_name):
180+
"""Downloads resource source file (original language) from
181+
Crowdin"""
182+
branch_id = self.__get_branch(branch)
183+
assert branch_id, (
184+
f'Unable to get resource {resource_name} for ' +
185+
f'branch {branch} because the branch doesn\'t exist')
186+
file_id = self.__get_resource_file(branch_id, resource_name)
187+
assert file_id, (
188+
f'Unable to get resource {resource_name} for ' +
189+
f'branch {branch} because the resource doesn\'t exist')
190+
url = self.__get_resource_download_url(file_id)
191+
r = requests.get(url, timeout=10)
192+
assert r.status_code == 200, \
193+
f'Aborting. Status code {r.status_code}: {r.content}'
194+
r.encoding = 'utf-8'
195+
content = r.text.encode('utf-8')
196+
return content
197+
198+
def get_resource_l10n(self, branch, resource_name, lang_code, file_ext):
199+
"""Downloads resource l10n from Crowdin for the given language"""
200+
assert file_ext in ('.grd',
201+
'.json'), (f'Unexpected file extension {file_ext}')
202+
if self.is_supported_language(lang_code):
203+
branch_id = self.__get_branch(branch)
204+
assert branch_id, (
205+
f'Unable to get {resource_name} l10n for ' +
206+
f'branch {branch} because the branch doesn\'t exist')
207+
file_id = self.__get_resource_file(branch_id, resource_name)
208+
assert file_id, (
209+
f'Unable to get {resource_name} l10n for ' +
210+
f'branch {branch} because the resource doesn\'t exist')
211+
url = self.__get_resource_translation_download_url(
212+
file_id, lang_code)
213+
r = requests.get(url, timeout=10)
214+
assert r.status_code == 200 or r.status_code == 204, \
215+
f'Aborting. Status code {r.status_code}: {r.content}'
216+
if r.status_code == 200:
217+
r.encoding = 'utf-8'
218+
if file_ext == '.grd':
219+
# Remove xml declaration header
220+
second_line = r.text.find('\n') + 1
221+
text = r.text[second_line:]
222+
else:
223+
text = r.text
224+
content = text.encode('utf-8')
225+
return content
226+
# Either unsupported language or status_code == 204 which means the
227+
# file is empty.
228+
if file_ext == '.json':
229+
# For json files we need to have content even if untranslated, so
230+
# get the source strings instead.
231+
return self.get_resource_source(branch, resource_name)
232+
# For GRDs we can just return an empty <resources> content:
233+
return '<resources></resources>'.encode('utf-8')
234+
235+
def upload_strings_l10n(self, branch, resource_name, translations,
236+
missing_only):
237+
"""Upload translations"""
238+
branch_id = self.__get_branch(branch)
239+
assert branch_id, (
240+
f'Unable to get resource {resource_name} for ' +
241+
f'branch {branch} because the branch doesn\'t exist')
242+
file_id = self.__get_resource_file(branch_id, resource_name)
243+
assert file_id, (
244+
f'Unable to get resource {resource_name} for ' +
245+
f'branch {branch} because the resource doesn\'t exist')
246+
all_strings = self.__get_resource_file_strings(file_id)
247+
# Translation is a dictionary whose keys are the string keys and values
248+
# are lists of tuples of language codes and translation strings.
249+
total = len(translations.keys())
250+
for idx, string_key in enumerate(translations.keys()):
251+
string_id = self.__get_string_id_from_key(all_strings, string_key)
252+
assert string_id, (f'Unable to find string by key {string_key} ' +
253+
f'in resource {resource_name}.')
254+
print(f'[{idx + 1}/{total}] Uploading translations for key ' +
255+
f'{string_key}')
256+
257+
for lang_code, translation in translations[string_key]:
258+
has_l10n = self.__has_source_string_l10n(string_id, lang_code)
259+
if has_l10n:
260+
if missing_only:
261+
print(f' Skipping {lang_code}: already translated.')
262+
continue
263+
self.__delete_source_string_l10n(string_id, lang_code)
264+
print(f' Uploading {lang_code}')
265+
self.__add_source_string_l10n(string_id, lang_code,
266+
translation)
267+
268+
def upload_grd_l10n_file(self, branch, upload_file_path, resource_name,
269+
lang):
270+
"""Upload grd l10n file to Crowdin"""
271+
# Create new storage for the file
272+
storage_id = self.__create_storage(upload_file_path)
273+
# Check if the branch already exists
274+
branch_id = self.__get_branch(branch)
275+
assert branch_id, f'Branch {branch} doesn\'t exist.'
276+
file_id = self.__get_resource_file(branch_id, resource_name)
277+
assert file_id, f'Resource {resource_name} doesn\'t exists.'
278+
return self.__upload_translation(file_id, storage_id, lang)

0 commit comments

Comments
 (0)