#!/usr/bin/env python3 # SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2015 Yorik van Havre * # * Copyright (c) 2021 Benjamin Nauck * # * Copyright (c) 2021 Mattias Pierre * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD is distributed in the hope that it will be useful, but * # * WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** """ 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 [] 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 """ # See crowdin API docs at https://crowdin.com/page/api 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"} # Locations that require QM file generation (predominantly Python workbenches) GENERATE_QM = { "AddonManager", "Arch", "Cloud", "Draft", "Inspection", "OpenSCAD", "Tux", "Help", } # locations list contains Module name, relative path to translation folder and relative path to qrc file 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 # how many % must be translated for the translation to be included in FreeCAD 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) # This blocks until all futures are complete and will also throw any exception for future in futures: print(f"{future.result()} done.") future.result() def load_token(): # try to read token from ~/.crowdin-freecad-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 # if file doesn't exist, read from CROWDIN_TOKEN return os.environ.get("CROWDIN_TOKEN") def updateqrc(qrcpath, lncode): "updates a qrc file with the given translation entry" # print("opening " + qrcpath + "...") # getting qrc file contents 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() # checking for existing entry name = "_" + lncode + ".qm" for r in resources: if name in r: # print("language already exists in qrc file") return # find the latest qm line 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 "" in resources[i]: pos = i - 1 if pos is None: print("ERROR: couldn't add qm files to this resource: " + qrcpath) sys.exit() # inserting new entry just after the last one 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 = " translations/" + modname + "_" + lncode + ".qm\n" # print "ERROR: no existing qm entry in this resource: Please add one manually " + qrcpath # sys.exit() # print("inserting line: ",line) resources.insert(pos + 1, line) # writing the file 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()) # read file contents f = open(cppfile, "r") cppcode = [] for l in f.readlines(): cppcode.append(l) f.close() # checking for existing entry lastentry = 0 for i, l in enumerate(cppcode): if l.startswith(" d->mapLanguageTopLevelDomain[QT_TR_NOOP("): lastentry = i if '"' + lncode + '"' in l: # print(lnname+" ("+lncode+") already exists in Translator.cpp") return # find the position to insert pos = lastentry + 1 if pos == 1: print("ERROR: couldn't update Translator.cpp") sys.exit() # inserting new entry just before the above line line = ' d->mapLanguageTopLevelDomain[QT_TR_NOOP("' + lnname + '")] = "' + lncode + '";\n' cppcode.insert(pos, line) print(lnname + " (" + lncode + ") added Translator.cpp") # writing the file 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] # filename fixes 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): # If this language code does not exist for the given TS file, bail out return shutil.copyfile(tsfilepath, newpath) if basename in GENERATE_QM: # print("generating qm files for",newpath,"...") 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": # never treat "english" translation... For now :) 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: # project_identifier = "freecad" 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)" ) # print(f" translation progress: {item['translationProgress']}%") # print(f" approval progress: {item['approvalProgress']}%") 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"]: # Find all ts files. However, this contains the lang-specific files too. Let's drop those all_ts_files = glob.glob("../**/*.ts", recursive=True) # Remove the file extensions ts_files_wo_ext = [splitext(f)[0] for f in all_ts_files] # Filter out any file that has another file as a substring. E.g. Draft is a substring of Draft_en 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, ) ) # Create tuples to map Crowdin name with local path name names_and_path = [(f"{basename(f)}.ts", f"{f}.ts") for f in main_ts_files] # Accommodate for legacy naming 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__)