3
# SPDX-License-Identifier: LGPL-2.1-or-later
4
# ***************************************************************************
6
# * Copyright (c) 2015 Yorik van Havre <yorik@uncreated.net> *
7
# * Copyright (c) 2021 Benjamin Nauck <benjamin@nauck.se> *
8
# * Copyright (c) 2021 Mattias Pierre <github@mattiaspierre.com> *
10
# * This file is part of FreeCAD. *
12
# * FreeCAD is free software: you can redistribute it and/or modify it *
13
# * under the terms of the GNU Lesser General Public License as *
14
# * published by the Free Software Foundation, either version 2.1 of the *
15
# * License, or (at your option) any later version. *
17
# * FreeCAD is distributed in the hope that it will be useful, but *
18
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
19
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
20
# * Lesser General Public License for more details. *
22
# * You should have received a copy of the GNU Lesser General Public *
23
# * License along with FreeCAD. If not, see *
24
# * <https://www.gnu.org/licenses/>. *
26
# ***************************************************************************
29
This utility offers several commands to interact with the FreeCAD project on
30
crowdin. For it to work, you need a ~/.crowdin-freecad-token file in your
31
user's folder, that contains the API access token that gives access to the
32
crowdin FreeCAD project. The API token can also be specified in the
33
CROWDIN_TOKEN environment variable.
35
The CROWDIN_PROJECT_ID environment variable can be used to use this script
40
updatecrowdin.py <command> [<arguments>]
44
gather: update all ts files found in the source code
46
status: prints a status of the translations
47
update / upload: updates crowdin the current version of .ts files
48
found in the source code
49
build: builds a new downloadable package on crowdin with all
51
build-status: shows the status of the current builds available on
53
download [build_id]: downloads build specified by 'build_id' or latest if
54
build_id is left blank
55
apply / install: applies downloaded translations to source code
56
(runs updatefromcrowdin.py)
60
./updatecrowdin.py update
62
Setting the project name adhoc:
64
CROWDIN_PROJECT_ID=some_project ./updatecrowdin.py update
67
# See crowdin API docs at https://crowdin.com/page/api
69
import concurrent.futures
79
from collections import namedtuple
80
from functools import lru_cache
81
from os.path import basename, splitext
82
from urllib.parse import quote_plus
83
from urllib.request import Request
84
from urllib.request import urlopen
85
from urllib.request import urlretrieve
86
from PySide2 import QtCore
88
TsFile = namedtuple("TsFile", ["filename", "src_path"])
90
LEGACY_NAMING_MAP = {"Draft.ts": "draft.ts"}
92
# Locations that require QM file generation (predominantly Python workbenches)
104
# locations list contains Module name, relative path to translation folder and relative path to qrc file
109
"../Mod/AddonManager/Resources/translations",
110
"../Mod/AddonManager/Resources/AddonManager.qrc",
112
["App", "../App/Resources/translations", "../App/Resources/App.qrc"],
113
["Arch", "../Mod/Arch/Resources/translations", "../Mod/Arch/Resources/Arch.qrc"],
116
"../Mod/Assembly/Gui/Resources/translations",
117
"../Mod/Assembly/Gui/Resources/Assembly.qrc",
121
"../Mod/Draft/Resources/translations",
122
"../Mod/Draft/Resources/Draft.qrc",
124
["Base", "../Base/Resources/translations", "../Base/Resources/Base.qrc"],
127
"../Mod/Drawing/Gui/Resources/translations",
128
"../Mod/Drawing/Gui/Resources/Drawing.qrc",
132
"../Mod/Fem/Gui/Resources/translations",
133
"../Mod/Fem/Gui/Resources/Fem.qrc",
135
["FreeCAD", "../Gui/Language", "../Gui/Language/translation.qrc"],
136
["Help", "../Mod/Help/Resources/translations", "../Mod/Help/Resources/Help.qrc"],
139
"../Mod/Inspection/Gui/Resources/translations",
140
"../Mod/Inspection/Gui/Resources/Inspection.qrc",
144
"../Mod/Material/Gui/Resources/translations",
145
"../Mod/Material/Gui/Resources/Material.qrc",
149
"../Mod/Mesh/Gui/Resources/translations",
150
"../Mod/Mesh/Gui/Resources/Mesh.qrc",
154
"../Mod/MeshPart/Gui/Resources/translations",
155
"../Mod/MeshPart/Gui/Resources/MeshPart.qrc",
159
"../Mod/OpenSCAD/Resources/translations",
160
"../Mod/OpenSCAD/Resources/OpenSCAD.qrc",
164
"../Mod/Part/Gui/Resources/translations",
165
"../Mod/Part/Gui/Resources/Part.qrc",
169
"../Mod/PartDesign/Gui/Resources/translations",
170
"../Mod/PartDesign/Gui/Resources/PartDesign.qrc",
174
"../Mod/CAM/Gui/Resources/translations",
175
"../Mod/CAM/Gui/Resources/CAM.qrc",
179
"../Mod/Points/Gui/Resources/translations",
180
"../Mod/Points/Gui/Resources/Points.qrc",
183
"ReverseEngineering",
184
"../Mod/ReverseEngineering/Gui/Resources/translations",
185
"../Mod/ReverseEngineering/Gui/Resources/ReverseEngineering.qrc",
189
"../Mod/Robot/Gui/Resources/translations",
190
"../Mod/Robot/Gui/Resources/Robot.qrc",
194
"../Mod/Sketcher/Gui/Resources/translations",
195
"../Mod/Sketcher/Gui/Resources/Sketcher.qrc",
199
"../Mod/Spreadsheet/Gui/Resources/translations",
200
"../Mod/Spreadsheet/Gui/Resources/Spreadsheet.qrc",
204
"../Mod/Start/Gui/Resources/translations",
205
"../Mod/Start/Gui/Resources/Start.qrc",
209
"../Mod/Test/Gui/Resources/translations",
210
"../Mod/Test/Gui/Resources/Test.qrc",
214
"../Mod/TechDraw/Gui/Resources/translations",
215
"../Mod/TechDraw/Gui/Resources/TechDraw.qrc",
217
["Tux", "../Mod/Tux/Resources/translations", "../Mod/Tux/Resources/Tux.qrc"],
220
"../Mod/Web/Gui/Resources/translations",
221
"../Mod/Web/Gui/Resources/Web.qrc",
225
THRESHOLD = 25 # how many % must be translated for the translation to be included in FreeCAD
230
BASE_URL = "https://api.crowdin.com/api/v2"
232
def __init__(self, token, project_identifier, multithread=True):
234
self.project_identifier = project_identifier
235
self.multithread = multithread
238
def _get_project_id(self):
239
url = f"{self.BASE_URL}/projects/"
240
response = self._make_api_req(url)
242
for project in [p["data"] for p in response]:
243
if project["identifier"] == project_identifier:
246
raise Exception("No project identifier found!")
248
def _make_project_api_req(self, project_path, *args, **kwargs):
249
url = f"{self.BASE_URL}/projects/{self._get_project_id()}{project_path}"
250
return self._make_api_req(url=url, *args, **kwargs)
252
def _make_api_req(self, url, extra_headers={}, method="GET", data=None):
253
headers = {"Authorization": "Bearer " + load_token(), **extra_headers}
255
if type(data) is dict:
256
headers["Content-Type"] = "application/json"
257
data = json.dumps(data).encode("utf-8")
259
request = Request(url, headers=headers, method=method, data=data)
260
return json.loads(urlopen(request).read())["data"]
262
def _get_files_info(self):
263
files = self._make_project_api_req("/files?limit=250")
264
return {f["data"]["path"].strip("/"): str(f["data"]["id"]) for f in files}
266
def _add_storage(self, filename, fp):
267
response = self._make_api_req(
268
f"{self.BASE_URL}/storages",
272
"Crowdin-API-FileName": filename,
273
"Content-Type": "application/octet-stream",
276
return response["id"]
278
def _update_file(self, project_id, ts_file, files_info):
279
filename = quote_plus(ts_file.filename)
281
with open(ts_file.src_path, "rb") as fp:
282
storage_id = self._add_storage(filename, fp)
284
if filename in files_info:
285
file_id = files_info[filename]
286
self._make_project_api_req(
290
"storageId": storage_id,
291
"updateOption": "keep_translations_and_approvals",
294
print(f"{filename} updated")
296
self._make_project_api_req("/files", data={"storageId": storage_id, "name": filename})
297
print(f"{filename} uploaded")
300
response = self._make_project_api_req("/languages/progress?limit=100")
301
return [item["data"] for item in response]
303
def download(self, build_id):
304
filename = f"{self.project_identifier}.zip"
305
response = self._make_project_api_req(f"/translations/builds/{build_id}/download")
306
urlretrieve(response["url"], filename)
307
print("download of " + filename + " complete")
310
self._make_project_api_req("/translations/builds", data={}, method="POST")
312
def build_status(self):
313
response = self._make_project_api_req("/translations/builds")
314
return [item["data"] for item in response]
316
def update(self, ts_files):
317
files_info = self._get_files_info()
320
with concurrent.futures.ThreadPoolExecutor() as executor:
321
for ts_file in ts_files:
323
future = executor.submit(
324
self._update_file, self.project_identifier, ts_file, files_info
326
futures.append(future)
328
self._update_file(self.project_identifier, ts_file, files_info)
330
# This blocks until all futures are complete and will also throw any exception
331
for future in futures:
336
# load API token stored in ~/.crowdin-freecad-token
337
config_file = os.path.expanduser("~") + os.sep + ".crowdin-freecad-token"
338
if os.path.exists(config_file):
339
with open(config_file) as file:
340
return file.read().strip()
344
def updateqrc(qrcpath, lncode):
346
"updates a qrc file with the given translation entry"
348
# print("opening " + qrcpath + "...")
350
# getting qrc file contents
351
if not os.path.exists(qrcpath):
352
print("ERROR: Resource file " + qrcpath + " doesn't exist")
354
f = open(qrcpath, "r")
356
for l in f.readlines():
360
# checking for existing entry
361
name = "_" + lncode + ".qm"
364
# print("language already exists in qrc file")
367
# find the latest qm line
369
for i in range(len(resources)):
370
if ".qm" in resources[i]:
373
print("No existing .qm file in this resource. Appending to the end position")
374
for i in range(len(resources)):
375
if "</qresource>" in resources[i]:
378
print("ERROR: couldn't add qm files to this resource: " + qrcpath)
381
# inserting new entry just after the last one
382
line = resources[pos]
384
line = re.sub("_.*\.qm", "_" + lncode + ".qm", line)
386
modname = os.path.splitext(os.path.basename(qrcpath))[0]
387
line = " <file>translations/" + modname + "_" + lncode + ".qm</file>\n"
388
# print "ERROR: no existing qm entry in this resource: Please add one manually " + qrcpath
390
# print("inserting line: ",line)
391
resources.insert(pos + 1, line)
394
f = open(qrcpath, "w")
398
print("successfully updated ", qrcpath)
401
def updateTranslatorCpp(lncode):
403
"updates the Translator.cpp file with the given translation entry"
405
cppfile = os.path.join(os.path.dirname(__file__), "..", "Gui", "Language", "Translator.cpp")
406
l = QtCore.QLocale(lncode)
407
lnname = l.languageToString(l.language())
410
f = open(cppfile, "r")
412
for l in f.readlines():
416
# checking for existing entry
418
for i, l in enumerate(cppcode):
419
if l.startswith(" d->mapLanguageTopLevelDomain[QT_TR_NOOP("):
421
if '"' + lncode + '"' in l:
422
# print(lnname+" ("+lncode+") already exists in Translator.cpp")
425
# find the position to insert
428
print("ERROR: couldn't update Translator.cpp")
431
# inserting new entry just before the above line
432
line = ' d->mapLanguageTopLevelDomain[QT_TR_NOOP("' + lnname + '")] = "' + lncode + '";\n'
433
cppcode.insert(pos, line)
434
print(lnname + " (" + lncode + ") added Translator.cpp")
437
f = open(cppfile, "w")
443
def doFile(tsfilepath, targetpath, lncode, qrcpath):
445
"updates a single ts file, and creates a corresponding qm file"
447
basename = os.path.basename(tsfilepath)[:-3]
449
if basename + ".ts" in LEGACY_NAMING_MAP.values():
450
basename = list(LEGACY_NAMING_MAP)[
451
list(LEGACY_NAMING_MAP.values()).index(basename + ".ts")
453
newname = basename + "_" + lncode + ".ts"
454
newpath = targetpath + os.sep + newname
455
if not os.path.exists(newpath):
456
# If this language code does not exist for the given TS file, bail out
458
shutil.copyfile(tsfilepath, newpath)
459
if basename in GENERATE_QM:
460
print(f"Generating QM for {basename}")
469
except Exception as e:
471
newqm = targetpath + os.sep + basename + "_" + lncode + ".qm"
472
if not os.path.exists(newqm):
473
print("ERROR: failed to create " + newqm + ", aborting")
475
updateqrc(qrcpath, lncode)
478
def doLanguage(lncode):
480
"treats a single language"
483
# never treat "english" translation... For now :)
487
if os.name == "posix":
490
print("Updating files for " + prefix + lncode + suffix + "...", end="")
491
for target in locations:
492
basefilepath = os.path.join(tempfolder, lncode, target[0] + ".ts")
493
targetpath = os.path.abspath(target[1])
494
qrcpath = os.path.abspath(target[2])
495
doFile(basefilepath, targetpath, lncode, qrcpath)
499
def applyTranslations(languages):
502
currentfolder = os.getcwd()
503
tempfolder = tempfile.mkdtemp()
504
print("creating temp folder " + tempfolder)
505
src = os.path.join(currentfolder, "freecad.zip")
506
dst = os.path.join(tempfolder, "freecad.zip")
507
if not os.path.exists(src):
508
print('freecad.zip file not found! Aborting. Run "download" command before this one.')
510
shutil.copyfile(src, dst)
512
zfile = zipfile.ZipFile("freecad.zip")
513
print("extracting freecad.zip...")
515
os.chdir(currentfolder)
517
if not os.path.exists(os.path.join(tempfolder, ln)):
518
print("ERROR: language path for " + ln + " not found!")
523
if __name__ == "__main__":
530
token = os.environ.get("CROWDIN_TOKEN", load_token())
531
if command and not token:
532
print("Token not found")
535
project_identifier = os.environ.get("CROWDIN_PROJECT_ID")
536
if not project_identifier:
537
project_identifier = "freecad"
538
# print('CROWDIN_PROJECT_ID env var must be set')
541
updater = CrowdinUpdater(token, project_identifier)
543
if command == "status":
544
status = updater.status()
545
status = sorted(status, key=lambda item: item["translationProgress"], reverse=True)
547
len([item for item in status if item["translationProgress"] > THRESHOLD]),
548
" languages with status > " + str(THRESHOLD) + "%:",
554
if os.name == "posix":
558
if item["translationProgress"] > 0:
559
if (item["translationProgress"] < THRESHOLD) and (not sep):
561
print("Other languages:")
569
+ str(item["translationProgress"])
571
+ str(item["approvalProgress"])
574
# print(f" translation progress: {item['translationProgress']}%")
575
# print(f" approval progress: {item['approvalProgress']}%")
577
elif command == "build-status":
578
for item in updater.build_status():
579
print(f" id: {item['id']} progress: {item['progress']}% status: {item['status']}")
581
elif command == "build":
584
elif command == "download":
586
updater.download(args[1])
588
stat = updater.build_status()
590
print("no builds found")
592
updater.download(stat[0]["id"])
594
print("available builds:")
597
f" id: {item['id']} progress: {item['progress']}% status: {item['status']}"
599
print("please specify a build id")
601
elif command in ["update", "upload"]:
602
# Find all ts files. However, this contains the lang-specific files too. Let's drop those
603
all_ts_files = glob.glob("../**/*.ts", recursive=True)
604
# Remove the file extensions
605
ts_files_wo_ext = [splitext(f)[0] for f in all_ts_files]
606
# Filter out any file that has another file as a substring. E.g. Draft is a substring of Draft_en
607
main_ts_files = list(
609
lambda f: not [a for a in ts_files_wo_ext if a in f and f != a],
613
# Create tuples to map Crowdin name with local path name
614
names_and_path = [(f"{basename(f)}.ts", f"{f}.ts") for f in main_ts_files]
615
# Accommodate for legacy naming
617
TsFile(LEGACY_NAMING_MAP[a] if a in LEGACY_NAMING_MAP else a, b)
618
for (a, b) in names_and_path
620
updater.update(ts_files)
622
elif command in ["apply", "install"]:
623
print("retrieving list of languages...")
624
status = updater.status()
625
status = sorted(status, key=lambda item: item["translationProgress"], reverse=True)
627
item["languageId"] for item in status if item["translationProgress"] > THRESHOLD
629
applyTranslations(languages)
630
print("Updating Translator.cpp...")
632
updateTranslatorCpp(ln)
634
elif command == "updateTranslator":
635
print("retrieving list of languages...")
636
status = updater.status()
637
status = sorted(status, key=lambda item: item["translationProgress"], reverse=True)
639
item["languageId"] for item in status if item["translationProgress"] > THRESHOLD
641
print("Updating Translator.cpp...")
643
updateTranslatorCpp(ln)
645
elif command == "gather":