| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | """ |
| | This utility offers several commands to interact with the FreeCAD project on |
| | crowdin. For it to work, you need a ~/.crowdin-freecad-token file in your |
| | user's folder, that contains the API access token that gives access to the |
| | crowdin FreeCAD project. The API token can also be specified in the |
| | CROWDIN_TOKEN environment variable. |
| | |
| | The CROWDIN_PROJECT_ID environment variable can be used to use this script |
| | in other projects. |
| | |
| | Usage: |
| | |
| | updatecrowdin.py <command> [<arguments>] |
| | |
| | Available commands: |
| | |
| | gather: update all ts files found in the source code |
| | (runs updatets.py) |
| | status: prints a status of the translations |
| | update / upload: updates crowdin the current version of .ts files |
| | found in the source code |
| | build: builds a new downloadable package on crowdin with all |
| | translated strings |
| | build-status: shows the status of the current builds available on |
| | crowdin |
| | download [build_id]: downloads build specified by 'build_id' or latest if |
| | build_id is left blank |
| | apply / install: applies downloaded translations to source code |
| | (runs updatefromcrowdin.py) |
| | |
| | Example: |
| | |
| | ./updatecrowdin.py update |
| | |
| | Setting the project name adhoc: |
| | |
| | CROWDIN_PROJECT_ID=some_project ./updatecrowdin.py update |
| | """ |
| |
|
| | |
| |
|
| | import concurrent.futures |
| | import glob |
| | import json |
| | import os |
| | import sys |
| | import shutil |
| | import subprocess |
| | import tempfile |
| | import zipfile |
| | import re |
| | from collections import namedtuple |
| | from functools import lru_cache |
| | from os.path import basename, splitext |
| | from urllib.parse import quote_plus |
| | from urllib.request import Request |
| | from urllib.request import urlopen |
| | from urllib.request import urlretrieve |
| |
|
| | try: |
| | from PySide6 import QtCore |
| | except ImportError: |
| | from PySide2 import QtCore |
| |
|
| | TsFile = namedtuple("TsFile", ["filename", "src_path"]) |
| |
|
| | LEGACY_NAMING_MAP = {"Draft.ts": "draft.ts"} |
| |
|
| | |
| | GENERATE_QM = { |
| | "AddonManager", |
| | "Arch", |
| | "Cloud", |
| | "Draft", |
| | "Inspection", |
| | "OpenSCAD", |
| | "Tux", |
| | "Help", |
| | } |
| |
|
| | |
| |
|
| | locations = [ |
| | ["App", "../App/Resources/translations", "../App/Resources/App.qrc"], |
| | ["Arch", "../Mod/BIM/Resources/translations", "../Mod/BIM/Resources/Arch.qrc"], |
| | ["App", "../App/Resources/translations", "../App/Resources/App.qrc"], |
| | ["Arch", "../Mod/BIM/Resources/translations", "../Mod/BIM/Resources/Arch.qrc"], |
| | [ |
| | "Assembly", |
| | "../Mod/Assembly/Gui/Resources/translations", |
| | "../Mod/Assembly/Gui/Resources/Assembly.qrc", |
| | ], |
| | [ |
| | "draft", |
| | "../Mod/Draft/Resources/translations", |
| | "../Mod/Draft/Resources/Draft.qrc", |
| | ], |
| | ["Base", "../Base/Resources/translations", "../Base/Resources/Base.qrc"], |
| | [ |
| | "Fem", |
| | "../Mod/Fem/Gui/Resources/translations", |
| | "../Mod/Fem/Gui/Resources/Fem.qrc", |
| | ], |
| | ["FreeCAD", "../Gui/Language", "../Gui/Language/translation.qrc"], |
| | ["Help", "../Mod/Help/Resources/translations", "../Mod/Help/Resources/Help.qrc"], |
| | [ |
| | "Inspection", |
| | "../Mod/Inspection/Gui/Resources/translations", |
| | "../Mod/Inspection/Gui/Resources/Inspection.qrc", |
| | ], |
| | [ |
| | "Material", |
| | "../Mod/Material/Gui/Resources/translations", |
| | "../Mod/Material/Gui/Resources/Material.qrc", |
| | ], |
| | [ |
| | "Measure", |
| | "../Mod/Measure/Gui/Resources/translations", |
| | "../Mod/Measure/Gui/Resources/Measure.qrc", |
| | ], |
| | [ |
| | "Mesh", |
| | "../Mod/Mesh/Gui/Resources/translations", |
| | "../Mod/Mesh/Gui/Resources/Mesh.qrc", |
| | ], |
| | [ |
| | "MeshPart", |
| | "../Mod/MeshPart/Gui/Resources/translations", |
| | "../Mod/MeshPart/Gui/Resources/MeshPart.qrc", |
| | ], |
| | [ |
| | "OpenSCAD", |
| | "../Mod/OpenSCAD/Resources/translations", |
| | "../Mod/OpenSCAD/Resources/OpenSCAD.qrc", |
| | ], |
| | [ |
| | "Part", |
| | "../Mod/Part/Gui/Resources/translations", |
| | "../Mod/Part/Gui/Resources/Part.qrc", |
| | ], |
| | [ |
| | "PartDesign", |
| | "../Mod/PartDesign/Gui/Resources/translations", |
| | "../Mod/PartDesign/Gui/Resources/PartDesign.qrc", |
| | ], |
| | [ |
| | "CAM", |
| | "../Mod/CAM/Gui/Resources/translations", |
| | "../Mod/CAM/Gui/Resources/CAM.qrc", |
| | ], |
| | [ |
| | "Points", |
| | "../Mod/Points/Gui/Resources/translations", |
| | "../Mod/Points/Gui/Resources/Points.qrc", |
| | ], |
| | [ |
| | "ReverseEngineering", |
| | "../Mod/ReverseEngineering/Gui/Resources/translations", |
| | "../Mod/ReverseEngineering/Gui/Resources/ReverseEngineering.qrc", |
| | ], |
| | [ |
| | "Robot", |
| | "../Mod/Robot/Gui/Resources/translations", |
| | "../Mod/Robot/Gui/Resources/Robot.qrc", |
| | ], |
| | [ |
| | "Sketcher", |
| | "../Mod/Sketcher/Gui/Resources/translations", |
| | "../Mod/Sketcher/Gui/Resources/Sketcher.qrc", |
| | ], |
| | [ |
| | "Spreadsheet", |
| | "../Mod/Spreadsheet/Gui/Resources/translations", |
| | "../Mod/Spreadsheet/Gui/Resources/Spreadsheet.qrc", |
| | ], |
| | [ |
| | "StartPage", |
| | "../Mod/Start/Gui/Resources/translations", |
| | "../Mod/Start/Gui/Resources/Start.qrc", |
| | ], |
| | [ |
| | "Surface", |
| | "../Mod/Surface/Gui/Resources/translations", |
| | "../Mod/Surface/Gui/Resources/Surface.qrc", |
| | ], |
| | [ |
| | "Test", |
| | "../Mod/Test/Gui/Resources/translations", |
| | "../Mod/Test/Gui/Resources/Test.qrc", |
| | ], |
| | [ |
| | "TechDraw", |
| | "../Mod/TechDraw/Gui/Resources/translations", |
| | "../Mod/TechDraw/Gui/Resources/TechDraw.qrc", |
| | ], |
| | ["Tux", "../Mod/Tux/Resources/translations", "../Mod/Tux/Resources/Tux.qrc"], |
| | ] |
| |
|
| | THRESHOLD = 25 |
| |
|
| |
|
| | class CrowdinUpdater: |
| |
|
| | BASE_URL = "https://api.crowdin.com/api/v2" |
| |
|
| | def __init__(self, token, project_identifier, multithread=True): |
| | self.token = token |
| | self.project_identifier = project_identifier |
| | self.multithread = multithread |
| |
|
| | @lru_cache() |
| | def _get_project_id(self): |
| | url = f"{self.BASE_URL}/projects/" |
| | response = self._make_api_req(url) |
| |
|
| | for project in [p["data"] for p in response]: |
| | if project["identifier"] == project_identifier: |
| | return project["id"] |
| |
|
| | raise Exception("No project identifier found!") |
| |
|
| | def _make_project_api_req(self, project_path, *args, **kwargs): |
| | url = f"{self.BASE_URL}/projects/{self._get_project_id()}{project_path}" |
| | return self._make_api_req(url=url, *args, **kwargs) |
| |
|
| | def _make_api_req(self, url, extra_headers={}, method="GET", data=None): |
| | headers = {"Authorization": "Bearer " + load_token(), **extra_headers} |
| |
|
| | if type(data) is dict: |
| | headers["Content-Type"] = "application/json" |
| | data = json.dumps(data).encode("utf-8") |
| |
|
| | request = Request(url, headers=headers, method=method, data=data) |
| | return json.loads(urlopen(request).read())["data"] |
| |
|
| | def _get_files_info(self): |
| | files = self._make_project_api_req("/files?limit=250") |
| | return {f["data"]["path"].strip("/"): str(f["data"]["id"]) for f in files} |
| |
|
| | def _add_storage(self, filename, fp): |
| | response = self._make_api_req( |
| | f"{self.BASE_URL}/storages", |
| | data=fp, |
| | method="POST", |
| | extra_headers={ |
| | "Crowdin-API-FileName": filename, |
| | "Content-Type": "application/octet-stream", |
| | }, |
| | ) |
| | return response["id"] |
| |
|
| | def _update_file(self, project_id, ts_file, files_info): |
| | filename = quote_plus(ts_file.filename) |
| |
|
| | with open(ts_file.src_path, "rb") as fp: |
| | storage_id = self._add_storage(filename, fp) |
| |
|
| | if filename in files_info: |
| | file_id = files_info[filename] |
| | self._make_project_api_req( |
| | f"/files/{file_id}", |
| | method="PUT", |
| | data={ |
| | "storageId": storage_id, |
| | "updateOption": "keep_translations_and_approvals", |
| | }, |
| | ) |
| | print(f"{filename} updated") |
| | else: |
| | self._make_project_api_req("/files", data={"storageId": storage_id, "name": filename}) |
| | print(f"{filename} uploaded") |
| |
|
| | def status(self): |
| | response = self._make_project_api_req("/languages/progress?limit=100") |
| | return [item["data"] for item in response] |
| |
|
| | def download(self, build_id): |
| | filename = f"{self.project_identifier}.zip" |
| | response = self._make_project_api_req(f"/translations/builds/{build_id}/download") |
| | urlretrieve(response["url"], filename) |
| | print("download of " + filename + " complete") |
| |
|
| | def build(self): |
| | self._make_project_api_req("/translations/builds", data={}, method="POST") |
| |
|
| | def build_status(self): |
| | response = self._make_project_api_req("/translations/builds") |
| | return [item["data"] for item in response] |
| |
|
| | def update(self, ts_files): |
| | files_info = self._get_files_info() |
| | futures = [] |
| |
|
| | with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: |
| | for ts_file in ts_files: |
| | if self.multithread: |
| | future = executor.submit( |
| | self._update_file, self.project_identifier, ts_file, files_info |
| | ) |
| | futures.append(future) |
| | else: |
| | self._update_file(self.project_identifier, ts_file, files_info) |
| |
|
| | |
| | for future in futures: |
| | print(f"{future.result()} done.") |
| | future.result() |
| |
|
| |
|
| | def load_token(): |
| | |
| | config_file = os.path.join(os.path.expanduser("~"), ".crowdin-freecad-token") |
| | if os.path.exists(config_file): |
| | with open(config_file, "r") as file: |
| | token = file.read().strip() |
| | if token: |
| | return token |
| | |
| | return os.environ.get("CROWDIN_TOKEN") |
| |
|
| |
|
| | def updateqrc(qrcpath, lncode): |
| | "updates a qrc file with the given translation entry" |
| |
|
| | |
| |
|
| | |
| | if not os.path.exists(qrcpath): |
| | print("ERROR: Resource file " + qrcpath + " doesn't exist") |
| | sys.exit() |
| | f = open(qrcpath, "r") |
| | resources = [] |
| | for l in f.readlines(): |
| | resources.append(l) |
| | f.close() |
| |
|
| | |
| | name = "_" + lncode + ".qm" |
| | for r in resources: |
| | if name in r: |
| | |
| | return |
| |
|
| | |
| | pos = None |
| | for i in range(len(resources)): |
| | if ".qm" in resources[i]: |
| | pos = i |
| | if pos is None: |
| | print("No existing .qm file in this resource. Appending to the end position") |
| | for i in range(len(resources)): |
| | if "</qresource>" in resources[i]: |
| | pos = i - 1 |
| | if pos is None: |
| | print("ERROR: couldn't add qm files to this resource: " + qrcpath) |
| | sys.exit() |
| |
|
| | |
| | line = resources[pos] |
| | if ".qm" in line: |
| | line = re.sub(r"_.*\.qm", "_" + lncode + ".qm", line) |
| | else: |
| | modname = os.path.splitext(os.path.basename(qrcpath))[0] |
| | line = " <file>translations/" + modname + "_" + lncode + ".qm</file>\n" |
| | |
| | |
| | |
| | resources.insert(pos + 1, line) |
| |
|
| | |
| | f = open(qrcpath, "w") |
| | for r in resources: |
| | f.write(r) |
| | f.close() |
| | print("successfully updated ", qrcpath) |
| |
|
| |
|
| | def updateTranslatorCpp(lncode): |
| | "updates the Translator.cpp file with the given translation entry" |
| |
|
| | cppfile = os.path.join(os.path.dirname(__file__), "..", "Gui", "Language", "Translator.cpp") |
| | l = QtCore.QLocale(lncode) |
| | lnname = QtCore.QLocale.languageToString(l.language()) |
| |
|
| | |
| | f = open(cppfile, "r") |
| | cppcode = [] |
| | for l in f.readlines(): |
| | cppcode.append(l) |
| | f.close() |
| |
|
| | |
| | lastentry = 0 |
| | for i, l in enumerate(cppcode): |
| | if l.startswith(" d->mapLanguageTopLevelDomain[QT_TR_NOOP("): |
| | lastentry = i |
| | if '"' + lncode + '"' in l: |
| | |
| | return |
| |
|
| | |
| | pos = lastentry + 1 |
| | if pos == 1: |
| | print("ERROR: couldn't update Translator.cpp") |
| | sys.exit() |
| |
|
| | |
| | line = ' d->mapLanguageTopLevelDomain[QT_TR_NOOP("' + lnname + '")] = "' + lncode + '";\n' |
| | cppcode.insert(pos, line) |
| | print(lnname + " (" + lncode + ") added Translator.cpp") |
| |
|
| | |
| | f = open(cppfile, "w") |
| | for r in cppcode: |
| | f.write(r) |
| | f.close() |
| |
|
| |
|
| | def doFile(tsfilepath, targetpath, lncode, qrcpath): |
| | "updates a single ts file, and creates a corresponding qm file" |
| |
|
| | basename = os.path.basename(tsfilepath)[:-3] |
| | |
| | if basename + ".ts" in LEGACY_NAMING_MAP.values(): |
| | basename = list(LEGACY_NAMING_MAP)[ |
| | list(LEGACY_NAMING_MAP.values()).index(basename + ".ts") |
| | ][:-3] |
| | newname = basename + "_" + lncode + ".ts" |
| | newpath = targetpath + os.sep + newname |
| | if not os.path.exists(tsfilepath): |
| | |
| | return |
| | shutil.copyfile(tsfilepath, newpath) |
| | if basename in GENERATE_QM: |
| | |
| | try: |
| | subprocess.run( |
| | [ |
| | "lrelease", |
| | newpath, |
| | ], |
| | timeout=5, |
| | ) |
| | except Exception as e: |
| | print(e) |
| | newqm = targetpath + os.sep + basename + "_" + lncode + ".qm" |
| | if not os.path.exists(newqm): |
| | print("ERROR: failed to create " + newqm + ", aborting") |
| | sys.exit() |
| | updateqrc(qrcpath, lncode) |
| |
|
| |
|
| | def doLanguage(lncode): |
| | "treats a single language" |
| |
|
| | if lncode == "en": |
| | |
| | return |
| | prefix = "" |
| | suffix = "" |
| | if os.name == "posix": |
| | prefix = "\033[;32m" |
| | suffix = "\033[0m" |
| | print("Updating files for " + prefix + lncode + suffix + "...", end="") |
| | for target in locations: |
| | basefilepath = os.path.join(tempfolder, lncode, target[0] + ".ts") |
| | targetpath = os.path.abspath(target[1]) |
| | qrcpath = os.path.abspath(target[2]) |
| | doFile(basefilepath, targetpath, lncode, qrcpath) |
| | print(" done") |
| |
|
| |
|
| | def applyTranslations(languages): |
| |
|
| | global tempfolder |
| | currentfolder = os.getcwd() |
| | tempfolder = tempfile.mkdtemp() |
| | print("creating temp folder " + tempfolder) |
| | src = os.path.join(currentfolder, "freecad.zip") |
| | dst = os.path.join(tempfolder, "freecad.zip") |
| | if not os.path.exists(src): |
| | print('freecad.zip file not found! Aborting. Run "download" command before this one.') |
| | sys.exit() |
| | shutil.copyfile(src, dst) |
| | os.chdir(tempfolder) |
| | zfile = zipfile.ZipFile("freecad.zip") |
| | print("extracting freecad.zip...") |
| | zfile.extractall() |
| | os.chdir(currentfolder) |
| | for ln in languages: |
| | if not os.path.exists(os.path.join(tempfolder, ln)): |
| | print("ERROR: language path for " + ln + " not found!") |
| | else: |
| | doLanguage(ln) |
| |
|
| |
|
| | if __name__ == "__main__": |
| | command = None |
| |
|
| | args = sys.argv[1:] |
| | if args: |
| | command = args[0] |
| |
|
| | token = os.environ.get("CROWDIN_TOKEN", load_token()) |
| | if command and not token: |
| | print("Token not found") |
| | sys.exit() |
| |
|
| | project_identifier = os.environ.get("CROWDIN_PROJECT_ID") |
| | if not project_identifier: |
| | |
| | print("CROWDIN_PROJECT_ID env var must be set") |
| | sys.exit() |
| |
|
| | updater = CrowdinUpdater(token, project_identifier) |
| |
|
| | if command == "status": |
| | status = updater.status() |
| | status = sorted(status, key=lambda item: item["translationProgress"], reverse=True) |
| | print( |
| | len([item for item in status if item["translationProgress"] > THRESHOLD]), |
| | " languages with status > " + str(THRESHOLD) + "%:", |
| | ) |
| | print(" ") |
| | sep = False |
| | prefix = "" |
| | suffix = "" |
| | if os.name == "posix": |
| | prefix = "\033[;32m" |
| | suffix = "\033[0m" |
| | for item in status: |
| | if item["translationProgress"] > 0: |
| | if (item["translationProgress"] < THRESHOLD) and (not sep): |
| | print(" ") |
| | print("Other languages:") |
| | print(" ") |
| | sep = True |
| | print( |
| | prefix |
| | + item["languageId"] |
| | + suffix |
| | + " " |
| | + str(item["translationProgress"]) |
| | + "% (" |
| | + str(item["approvalProgress"]) |
| | + "% approved)" |
| | ) |
| | |
| | |
| |
|
| | elif command == "build-status": |
| | for item in updater.build_status(): |
| | print(f" id: {item['id']} progress: {item['progress']}% status: {item['status']}") |
| |
|
| | elif command == "build": |
| | updater.build() |
| |
|
| | elif command == "download": |
| | if len(args) == 2: |
| | updater.download(args[1]) |
| | else: |
| | stat = updater.build_status() |
| | if not stat: |
| | print("no builds found") |
| | elif len(stat) == 1: |
| | updater.download(stat[0]["id"]) |
| | else: |
| | print("available builds:") |
| | for item in stat: |
| | print( |
| | f" id: {item['id']} progress: {item['progress']}% status: {item['status']}" |
| | ) |
| | print("please specify a build id") |
| |
|
| | elif command in ["update", "upload"]: |
| | |
| | all_ts_files = glob.glob("../**/*.ts", recursive=True) |
| | |
| | ts_files_wo_ext = [splitext(f)[0] for f in all_ts_files] |
| | |
| | main_ts_files = list( |
| | filter( |
| | lambda f: not [a for a in ts_files_wo_ext if a in f and f != a], |
| | ts_files_wo_ext, |
| | ) |
| | ) |
| | |
| | names_and_path = [(f"{basename(f)}.ts", f"{f}.ts") for f in main_ts_files] |
| | |
| | ts_files = [ |
| | TsFile(LEGACY_NAMING_MAP[a] if a in LEGACY_NAMING_MAP else a, b) |
| | for (a, b) in names_and_path |
| | ] |
| | updater.update(ts_files) |
| |
|
| | elif command in ["apply", "install"]: |
| | print("retrieving list of languages...") |
| | status = updater.status() |
| | status = sorted(status, key=lambda item: item["translationProgress"], reverse=True) |
| | languages = [ |
| | item["languageId"] for item in status if item["translationProgress"] > THRESHOLD |
| | ] |
| | applyTranslations(languages) |
| | print("Updating Translator.cpp...") |
| | for ln in languages: |
| | updateTranslatorCpp(ln) |
| |
|
| | elif command == "updateTranslator": |
| | print("retrieving list of languages...") |
| | status = updater.status() |
| | status = sorted(status, key=lambda item: item["translationProgress"], reverse=True) |
| | languages = [ |
| | item["languageId"] for item in status if item["translationProgress"] > THRESHOLD |
| | ] |
| | print("Updating Translator.cpp...") |
| | for ln in languages: |
| | updateTranslatorCpp(ln) |
| |
|
| | elif command == "gather": |
| | import updatets |
| |
|
| | updatets.main() |
| |
|
| | else: |
| | print(__doc__) |
| |
|