From 28c060dd336b5d6e72b1733111d59f188e53e28e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 10:24:44 +0100 Subject: [PATCH 001/142] Add scripts to allow addons from personal repos to be synchronized with Crowdin --- _l10n/crowdinSync.py | 92 ++++ _l10n/files.json | 1 + _l10n/l10nUtil.py | 978 +++++++++++++++++++++++++++++++++++++ _l10n/markdownTranslate.py | 733 +++++++++++++++++++++++++++ _l10n/md2html.py | 197 ++++++++ 5 files changed, 2001 insertions(+) create mode 100644 _l10n/crowdinSync.py create mode 100644 _l10n/files.json create mode 100644 _l10n/l10nUtil.py create mode 100644 _l10n/markdownTranslate.py create mode 100644 _l10n/md2html.py diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py new file mode 100644 index 0000000..1a56070 --- /dev/null +++ b/_l10n/crowdinSync.py @@ -0,0 +1,92 @@ +# A part of NonVisual Desktop Access (NVDA) +# based on file from https://github.com/jcsteh/osara +# Copyright (C) 2023-2024 NV Access Limited, James Teh +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + + +import argparse +import os + +import requests + +from l10nUtil import getFiles + +AUTH_TOKEN = os.getenv("crowdinAuthToken", "").strip() +if not AUTH_TOKEN: + raise ValueError("crowdinAuthToken environment variable not set") +PROJECT_ID = os.getenv("crowdinProjectID", "").strip() +if not PROJECT_ID: + raise ValueError("crowdinProjectID environment variable not set") + + +def request( + path: str, + method=requests.get, + headers: dict[str, str] | None = None, + **kwargs, +) -> requests.Response: + if headers is None: + headers = {} + headers["Authorization"] = f"Bearer {AUTH_TOKEN}" + r = method( + f"https://api.crowdin.com/api/v2/{path}", + headers=headers, + **kwargs, + ) + # Convert errors to exceptions, but print the response before raising. + try: + r.raise_for_status() + except requests.exceptions.HTTPError: + print(r.json()) + raise + return r + + +def projectRequest(path: str, **kwargs) -> requests.Response: + return request(f"projects/{PROJECT_ID}/{path}", **kwargs) + + +def uploadSourceFile(localFilePath: str) -> None: + files = getFiles() + fn = os.path.basename(localFilePath) + crowdinFileID = files.get(fn) + print(f"Uploading {localFilePath} to Crowdin temporary storage as {fn}") + with open(localFilePath, "rb") as f: + r = request( + "storages", + method=requests.post, + headers={"Crowdin-API-FileName": fn}, + data=f, + ) + storageID = r.json()["data"]["id"] + print(f"Updating file {crowdinFileID} on Crowdin with storage ID {storageID}") + r = projectRequest( + f"files/{crowdinFileID}", + method=requests.put, + json={"storageId": storageID}, + ) + revisionId = r.json()["data"]["revisionId"] + print(f"Updated to revision {revisionId}") + + +def main(): + parser = argparse.ArgumentParser( + description="Syncs translations with Crowdin.", + ) + commands = parser.add_subparsers(dest="command", required=True) + uploadCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + # uploadCommand.add_argument("crowdinFileID", type=int, help="The Crowdin file ID.") + uploadCommand.add_argument("localFilePath", help="The path to the local file.") + args = parser.parse_args() + if args.command == "uploadSourceFile": + uploadSourceFile(args.localFilePath) + else: + raise ValueError(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + main() diff --git a/_l10n/files.json b/_l10n/files.json new file mode 100644 index 0000000..9264714 --- /dev/null +++ b/_l10n/files.json @@ -0,0 +1 @@ +{"emoticons.pot": 176, "emoticons.xliff": 178, "goldwave.pot": 180, "goldwave.xliff": 182, "eMule.pot": 194, "enhancedTouchGestures.pot": 210, "resourceMonitor.pot": 214, "stationPlaylist.pot": 218, "cursorLocator.pot": 224, "pcKbBrl.pot": 228, "readFeeds.pot": 232, "reportSymbols.pot": 236, "urlShortener.pot": 240, "customNotifications.pot": 244, "readonlyProfiles.pot": 248, "enhancedAnnotations.pot": 252, "clipContentsDesigner.pot": 256, "clipContentsDesigner.xliff": 258, "controlUsageAssistant.pot": 260, "controlUsageAssistant.xliff": 262, "eMule.xliff": 264, "enhancedAnnotations.xliff": 266, "customNotifications.xliff": 268, "readonlyProfiles.xliff": 270, "urlShortener.xliff": 272, "reportSymbols.xliff": 274, "pcKbBrl.xliff": 276, "readFeeds.xliff": 278, "stationPlaylist.xliff": 282, "resourceMonitor.xliff": 284, "enhancedTouchGestures.xliff": 286, "rdAccess.pot": 288, "rdAccess.xliff": 290, "winMag.pot": 292, "winMag.xliff": 294, "charInfo.pot": 296, "charInfo.xliff": 298, "BMI.pot": 300, "BMI.xliff": 302, "tonysEnhancements.pot": 304, "tonysEnhancements.xliff": 306, "nvdaDevTestToolbox.pot": 308, "nvdaDevTestToolbox.xliff": 310, "easyTableNavigator.pot": 312, "easyTableNavigator.xliff": 314, "updateChannel.pot": 320, "updateChannel.xliff": 322, "instantTranslate.pot": 324, "instantTranslate.xliff": 326, "unicodeBrailleInput.pot": 328, "unicodeBrailleInput.xliff": 330, "columnsReview.pot": 332, "columnsReview.xliff": 334, "Access8Math.pot": 336, "Access8Math.xliff": 338, "systrayList.pot": 340, "systrayList.xliff": 342, "winWizard.pot": 344, "winWizard.xliff": 346, "speechLogger.pot": 348, "speechLogger.xliff": 350, "sayProductNameAndVersion.pot": 352, "sayProductNameAndVersion.xliff": 354, "objPad.pot": 356, "objPad.xliff": 358, "SentenceNav.pot": 360, "SentenceNav.xliff": 362, "wordNav.pot": 364, "wordNav.xliff": 366, "goldenCursor.pot": 368, "goldenCursor.xliff": 370, "MSEdgeDiscardAnnouncements.pot": 372, "MSEdgeDiscardAnnouncements.xliff": 374, "dayOfTheWeek.pot": 376, "dayOfTheWeek.xliff": 378, "outlookExtended.pot": 380, "outlookExtended.xliff": 382, "proxy.pot": 384, "proxy.xliff": 386, "searchWith.pot": 388, "searchWith.xliff": 390, "sayCurrentKeyboardLanguage.pot": 392, "sayCurrentKeyboardLanguage.xliff": 394, "robEnhancements.pot": 396, "robEnhancements.xliff": 398, "objWatcher.pot": 400, "objWatcher.xliff": 402, "mp3DirectCut.pot": 404, "mp3DirectCut.xliff": 406, "beepKeyboard.pot": 408, "beepKeyboard.xliff": 410, "numpadNavMode.pot": 412, "numpadNavMode.xliff": 414, "dropbox.pot": 416, "dropbox.xliff": 418, "reviewCursorCopier.pot": 420, "reviewCursorCopier.xliff": 422, "inputLock.pot": 424, "inputLock.xliff": 426, "debugHelper.pot": 428, "debugHelper.xliff": 430, "virtualRevision.pot": 432, "virtualRevision.xliff": 434, "cursorLocator.xliff": 436, "evtTracker.pot": 438, "evtTracker.xliff": 440} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py new file mode 100644 index 0000000..00bee4c --- /dev/null +++ b/_l10n/l10nUtil.py @@ -0,0 +1,978 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024-2025 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import crowdin_api as crowdin +import tempfile +import lxml.etree +import os +import shutil +import argparse +import markdownTranslate +import md2html +import requests +import codecs +import re +import subprocess +import sys +import zipfile +import time +import json + +CROWDIN_PROJECT_ID = 780748 +POLLING_INTERVAL_SECONDS = 5 +EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes +JSON_FILE = os.path.join(os.path.dirname(__file__), "files.json") + + +def fetchCrowdinAuthToken() -> str: + """ + Fetch the Crowdin auth token from the ~/.nvda_crowdin file or prompt the user for it. + If provided by the user, the token will be saved to the ~/.nvda_crowdin file. + :return: The auth token + """ + crowdinAuthToken = os.getenv("crowdinAuthToken", "") + if crowdinAuthToken: + print("Using Crowdin auth token from environment variable.") + return crowdinAuthToken + token_path = os.path.expanduser("~/.nvda_crowdin") + if os.path.exists(token_path): + with open(token_path, "r") as f: + token = f.read().strip() + print("Using auth token from ~/.nvda_crowdin") + return token + print("A Crowdin auth token is required to proceed.") + print("Please visit https://crowdin.com/settings#api-key") + print("Create a personal access token with translations permissions, and enter it below.") + token = input("Enter Crowdin auth token: ").strip() + with open(token_path, "w") as f: + f.write(token) + return token + + +_crowdinClient = None + + +def getCrowdinClient() -> crowdin.CrowdinClient: + """ + Create or fetch the Crowdin client instance. + :return: The Crowdin client + """ + global _crowdinClient + if _crowdinClient is None: + token = fetchCrowdinAuthToken() + _crowdinClient = crowdin.CrowdinClient(project_id=CROWDIN_PROJECT_ID, token=token) + return _crowdinClient + + +def fetchLanguageFromXliff(xliffPath: str, source: bool = False) -> str: + """ + Fetch the language from an xliff file. + This function also prints a message to the console stating the detected language if found, or a warning if not found. + :param xliffPath: Path to the xliff file + :param source: If True, fetch the source language, otherwise fetch the target language + :return: The language code + """ + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + lang = xliffRoot.get("srcLang" if source else "trgLang") + if lang is None: + print(f"Could not detect language for xliff file {xliffPath}, {source=}") + else: + print(f"Detected language {lang} for xliff file {xliffPath}, {source=}") + return lang + + +def preprocessXliff(xliffPath: str, outputPath: str): + """ + Replace corrupt or empty translated segment targets with the source text, + marking the segment again as "initial" state. + This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. + :param xliffPath: Path to the xliff file to be processed + :param outputPath: Path to the resulting xliff file + """ + print(f"Preprocessing xliff file at {xliffPath}") + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + file = xliffRoot.find("./xliff:file", namespaces=namespace) + units = file.findall("./xliff:unit", namespaces=namespace) + segmentCount = 0 + emptyTargetCount = 0 + corruptTargetcount = 0 + for unit in units: + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + print("Warning: No segment element in unit") + continue + source = segment.find("./xliff:source", namespaces=namespace) + if source is None: + print("Warning: No source element in segment") + continue + sourceText = source.text + segmentCount += 1 + target = segment.find("./xliff:target", namespaces=namespace) + if target is None: + continue + targetText = target.text + # Correct empty targets + if not targetText: + emptyTargetCount += 1 + target.text = sourceText + segment.set("state", "initial") + # Correct corrupt target tags + elif targetText in ( + "", + "<target/>", + "", + "<target></target>", + ): + corruptTargetcount += 1 + target.text = sourceText + segment.set("state", "initial") + xliff.write(outputPath, encoding="utf-8") + print( + f"Processed {segmentCount} segments, removing {emptyTargetCount} empty targets, {corruptTargetcount} corrupt targets", + ) + + +def stripXliff(xliffPath: str, outputPath: str, oldXliffPath: str | None = None): + """ + Removes notes and skeleton elements from an xliff file before upload to Crowdin. + Removes empty and corrupt translations. + Removes untranslated segments. + Removes existing translations if an old xliff file is provided. + This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. + :param xliffPath: Path to the xliff file to be stripped + :param outputPath: Path to the resulting xliff file + :param oldXliffPath: Path to the old xliff file containing existing translations that should be also stripped. + """ + print(f"Creating stripped xliff at {outputPath} from {xliffPath}") + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + oldXliffRoot = None + if oldXliffPath: + oldXliff = lxml.etree.parse(oldXliffPath) + oldXliffRoot = oldXliff.getroot() + if oldXliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {oldXliffPath}") + skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) + if skeletonNode is not None: + skeletonNode.getparent().remove(skeletonNode) + file = xliffRoot.find("./xliff:file", namespaces=namespace) + units = file.findall("./xliff:unit", namespaces=namespace) + segmentCount = 0 + untranslatedCount = 0 + emptyCount = 0 + corruptCount = 0 + existingTranslationCount = 0 + for unit in units: + unitID = unit.get("id") + notes = unit.find("./xliff:notes", namespaces=namespace) + if notes is not None: + unit.remove(notes) + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + print("Warning: No segment element in unit") + continue + segmentCount += 1 + state = segment.get("state") + if state == "initial": + file.remove(unit) + untranslatedCount += 1 + continue + target = segment.find("./xliff:target", namespaces=namespace) + if target is None: + file.remove(unit) + untranslatedCount += 1 + continue + targetText = target.text + if not targetText: + emptyCount += 1 + file.remove(unit) + continue + elif targetText in ( + "", + "<target/>", + "", + "<target></target>", + ): + corruptCount += 1 + file.remove(unit) + continue + if oldXliffRoot: + # Remove existing translations + oldTarget = oldXliffRoot.find( + f"./xliff:file/xliff:unit[@id='{unitID}']/xliff:segment/xliff:target", + namespaces=namespace, + ) + if oldTarget is not None and oldTarget.getparent().get("state") != "initial": + if oldTarget.text == targetText: + file.remove(unit) + existingTranslationCount += 1 + xliff.write(outputPath, encoding="utf-8") + if corruptCount > 0: + print(f"Removed {corruptCount} corrupt translations.") + if emptyCount > 0: + print(f"Removed {emptyCount} empty translations.") + if existingTranslationCount > 0: + print(f"Ignored {existingTranslationCount} existing translations.") + keptTranslations = segmentCount - untranslatedCount - emptyCount - corruptCount - existingTranslationCount + print(f"Added or changed {keptTranslations} translations.") + + +def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): + """ + Download a translation file from Crowdin. + :param crowdinFilePath: The Crowdin file path + :param localFilePath: The path to save the local file + :param language: The language code to download the translation for + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(crowdinFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(crowdinFilePath) + print(f"Requesting export of {crowdinFilePath} for {language} from Crowdin") + res = getCrowdinClient().translations.export_project_translation( + fileIds=[fileId], + targetLanguageId=language, + ) + if res is None: + raise ValueError("Crowdin export failed") + download_url = res["data"]["url"] + print(f"Downloading from {download_url}") + with open(localFilePath, "wb") as f: + r = requests.get(download_url) + f.write(r.content) + print(f"Saved to {localFilePath}") + + +def uploadSourceFile(localFilePath: str): + """ + Upload a source file to Crowdin. + :param localFilePath: The path to the local file to be uploaded + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(localFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(localFilePath) + res = getCrowdinClient().storages.add_storage( + open(localFilePath, "rb"), + ) + if res is None: + raise ValueError("Crowdin storage upload failed") + storageId = res["data"]["id"] + print(f"Stored with ID {storageId}") + filename = os.path.basename(localFilePath) + fileId = files.get(filename) + print(f"File ID: {fileId}") + match fileId: + case None: + if os.path.splitext(filename)[1] == ".pot": + title=f"{os.path.splitext(filename)[0]} interface" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + else: + title=f"{os.path.splitext(filename)[0]} documentation" + exportPattern =f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern + } + print(f"Importing source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file(storageId=storageId, projectId=CROWDIN_PROJECT_ID, name=filename, title=title, exportOptions=exportOptions) + print("Done") + case _: + res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) + + +def getFiles() -> dict: + """Gets files from Crowdin, and write them to a json file.""" + + res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, limit=500) + if res is None: + raise ValueError("Getting files from Crowdin failed") + dictionary = dict() + data = res["data"] + for file in data: + fileInfo = file["data"] + name = fileInfo["name"] + id = fileInfo["id"] + dictionary[name] = id + with open(JSON_FILE, "w", encoding="utf-8") as jsonFile: + json.dump(dictionary, jsonFile, ensure_ascii=False) + return dictionary + + +def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): + """ + Upload a translation file to Crowdin. + :param crowdinFilePath: The Crowdin file path + :param localFilePath: The path to the local file to be uploaded + :param language: The language code to upload the translation for + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(crowdinFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(crowdinFilePath) + print(f"Uploading {localFilePath} to Crowdin") + res = getCrowdinClient().storages.add_storage( + open(localFilePath, "rb"), + ) + if res is None: + raise ValueError("Crowdin storage upload failed") + storageId = res["data"]["id"] + print(f"Stored with ID {storageId}") + print(f"Importing translation for {crowdinFilePath} in {language} from storage with ID {storageId}") + res = getCrowdinClient().translations.upload_translation( + fileId=fileId, + languageId=language, + storageId=storageId, + autoApproveImported=True, + importEqSuggestions=True, + ) + print("Done") + + +def exportTranslations(outputDir: str, language: str | None = None): + """ + Export translation files from Crowdin as a bundle. + :param outputDir: Directory to save translation files. + :param language: The language code to export (e.g., 'es', 'fr', 'de'). + If None, exports all languages. + """ + + # Create output directory if it doesn't exist + os.makedirs(outputDir, exist_ok=True) + + client = getCrowdinClient() + + requestData = { + "skipUntranslatedStrings": False, + "skipUntranslatedFiles": True, + "exportApprovedOnly": False, + } + + if language is not None: + requestData["targetLanguageIds"] = [language] + + if language is None: + print("Requesting export of all translations from Crowdin...") + else: + print(f"Requesting export of all translations for language: {language}") + build_res = client.translations.build_project_translation(request_data=requestData) + + if language is None: + zip_filename = "translations.zip" + else: + zip_filename = f"translations_{language}.zip" + + if build_res is None: + raise ValueError("Failed to start translation build") + + build_id = build_res["data"]["id"] + print(f"Build started with ID: {build_id}") + + # Wait for the build to complete + print("Waiting for build to complete...") + while True: + status_res = client.translations.check_project_build_status(build_id) + if status_res is None: + raise ValueError("Failed to check build status") + + status = status_res["data"]["status"] + progress = status_res["data"]["progress"] + print(f"Build status: {status} ({progress}%)") + + if status == "finished": + break + elif status == "failed": + raise ValueError("Translation build failed") + + time.sleep(POLLING_INTERVAL_SECONDS) + + # Download the completed build + print("Downloading translations archive...") + download_res = client.translations.download_project_translations(build_id) + if download_res is None: + raise ValueError("Failed to get download URL") + + download_url = download_res["data"]["url"] + print(f"Downloading from {download_url}") + + # Download and extract the ZIP file + zip_path = os.path.join(outputDir, zip_filename) + response = requests.get(download_url, stream=True, timeout=EXPORT_TIMEOUT_SECONDS) + response.raise_for_status() + + with open(zip_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + print(f"Archive saved to {zip_path}") + print("Extracting translations...") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(outputDir) + + # Remove the zip file + os.remove(zip_path) + + if language is None: + print(f"\nExport complete! All translations extracted to '{outputDir}' directory.") + else: + print(f"\nExport complete! All {language} translations extracted to '{outputDir}' directory.") + + +class _PoChecker: + """Checks a po file for errors not detected by msgfmt. + This first runs msgfmt to check for syntax errors. + It then checks for mismatched Python percent and brace interpolations. + Construct an instance and call the L{check} method. + """ + + FUZZY = "#, fuzzy" + MSGID = "msgid" + MSGID_PLURAL = "msgid_plural" + MSGSTR = "msgstr" + + def __init__(self, po: str): + """Constructor. + :param po: The path to the po file to check. + """ + self._poPath = po + with codecs.open(po, "r", "utf-8") as file: + self._poContent = file.readlines() + self._string: str | None = None + + self.alerts: list[str] = [] + """List of error and warning messages found in the po file.""" + + self.hasSyntaxError: bool = False + """Whether there is a syntax error in the po file.""" + + self.warningCount: int = 0 + """Number of warnings found.""" + + self.errorCount: int = 0 + """Number of errors found.""" + + def _addToString(self, line: list[str], startingCommand: str | None = None) -> None: + """Helper function to add a line to the current string. + :param line: The line to add. + :param startingCommand: The command that started this string, if any. + This is used to determine whether to strip the command and quotes. + """ + if startingCommand: + # Strip the command and the quotes. + self._string = line[len(startingCommand) + 2 : -1] + else: + # Strip the quotes. + self._string += line[1:-1] + + def _finishString(self) -> str: + """Helper function to finish the current string. + :return: The finished string. + """ + string = self._string + self._string = None + return string + + def _messageAlert(self, alert: str, isError: bool = True) -> None: + """Helper function to add an alert about a message. + :param alert: The alert message. + :param isError: Whether this is an error or a warning. + """ + if self._fuzzy: + # Fuzzy messages don't get used, so this shouldn't be considered an error. + isError = False + if isError: + self.errorCount += 1 + else: + self.warningCount += 1 + if self._fuzzy: + msgType = "Fuzzy message" + else: + msgType = "Message" + self.alerts.append( + f"{msgType} starting on line {self._messageLineNum}\n" + f'Original: "{self._msgid}"\n' + f'Translated: "{self._msgstr[-1]}"\n' + f"{'ERROR' if isError else 'WARNING'}: {alert}", + ) + + @property + def MSGFMT_PATH(self) -> str: + try: + # When running from source, miscDeps is the sibling of parent this script. + _MSGFMT = os.path.join(os.path.dirname(__file__), "..", "miscDeps", "tools", "msgfmt.exe") + except NameError: + # When running from a frozen executable, __file__ is not defined. + # In this case, we use the distribution path. + # When running from a distribution, source/l10nUtil.py is built to l10nUtil.exe. + # miscDeps is the sibling of this script in the distribution. + _MSGFMT = os.path.join(sys.prefix, "miscDeps", "tools", "msgfmt.exe") + + if not os.path.exists(_MSGFMT): + raise FileNotFoundError( + "msgfmt executable not found. " + "Please ensure that miscDeps/tools/msgfmt.exe exists in the source tree or distribution.", + ) + return _MSGFMT + + def _checkSyntax(self) -> None: + """Check the syntax of the po file using msgfmt. + This will set the hasSyntaxError attribute to True if there is a syntax error. + """ + + result = subprocess.run( + (self.MSGFMT_PATH, "-o", "-", self._poPath), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, # Ensures stderr is a text stream + ) + if result.returncode != 0: + output = result.stderr.rstrip().replace("\r\n", "\n") + self.alerts.append(output) + self.hasSyntaxError = True + self.errorCount = 1 + + def _checkMessages(self) -> None: + command = None + self._msgid = None + self._msgid_plural = None + self._msgstr = None + nextFuzzy = False + self._fuzzy = False + for lineNum, line in enumerate(self._poContent, 1): + line = line.strip() + if line.startswith(self.FUZZY): + nextFuzzy = True + continue + elif line.startswith(self.MSGID) and not line.startswith(self.MSGID_PLURAL): + # New message. + if self._msgstr is not None: + self._msgstr[-1] = self._finishString() + # Check the message we just handled. + self._checkMessage() + command = self.MSGID + start = command + self._messageLineNum = lineNum + self._fuzzy = nextFuzzy + nextFuzzy = False + elif line.startswith(self.MSGID_PLURAL): + self._msgid = self._finishString() + command = self.MSGID_PLURAL + start = command + elif line.startswith(self.MSGSTR): + self._handleMsgStrReaching(lastCommand=command) + command = self.MSGSTR + start = line[: line.find(" ")] + elif line.startswith('"'): + # Continuing a string. + start = None + else: + # This line isn't of interest. + continue + self._addToString(line, startingCommand=start) + if command == self.MSGSTR: + # Handle the last message. + self._msgstr[-1] = self._finishString() + self._checkMessage() + + def _handleMsgStrReaching(self, lastCommand: str) -> None: + """Helper function used by _checkMessages to handle the required processing when reaching a line + starting with "msgstr". + :param lastCommand: the current command just before the msgstr line is reached. + """ + + # Finish the string of the last command and check the message if it was an msgstr + if lastCommand == self.MSGID: + self._msgid = self._finishString() + elif lastCommand == self.MSGID_PLURAL: + self._msgid_plural = self._finishString() + elif lastCommand == self.MSGSTR: + self._msgstr[-1] = self._finishString() + self._checkMessage() + else: + raise RuntimeError(f"Unexpected command before line {self._messageLineNum}: {lastCommand}") + + # For first msgstr create the msgstr list + if lastCommand != self.MSGSTR: + self._msgstr = [] + + # Initiate the string for the current msgstr + self._msgstr.append("") + + def check(self) -> bool: + """Check the file. + Once this returns, you can call getReport to obtain a report. + This method should not be called more than once. + :return: True if the file is okay, False if there were problems. + """ + self._checkSyntax() + if self.alerts: + return False + self._checkMessages() + if self.alerts: + return False + return True + + # e.g. %s %d %10.2f %-5s (but not %%) or %%(name)s %(name)d + RE_UNNAMED_PERCENT = re.compile( + # Does not include optional mapping key, as that's handled by a different regex + r""" + (?:(?<=%%)|(? tuple[list[str], set[str], set[str]]: + """Get the percent and brace interpolations in a string. + :param text: The text to check. + :return: A tuple of a list and two sets: + - unnamed percent interpolations (e.g. %s, %d) + - named percent interpolations (e.g. %(name)s) + - brace format interpolations (e.g. {name}, {name:format}) + """ + unnamedPercent = self.RE_UNNAMED_PERCENT.findall(text) + namedPercent = set(self.RE_NAMED_PERCENT.findall(text)) + formats = set() + for m in self.RE_FORMAT.finditer(text): + if not m.group(1): + self._messageAlert( + "Unspecified positional argument in brace format", + # Skip as error as many of these had been introduced in the source .po files. + # These should be fixed in the source .po files to add names to instances of "{}". + # This causes issues where the order of the arguments change in the string. + # e.g. "Character: {}\nReplacement: {}" being translated to "Replacement: {}\nCharacter: {}" + # will result in the expected interpolation being in the wrong place. + # This should be changed isError=True. + isError=False, + ) + formats.add(m.group(0)) + return unnamedPercent, namedPercent, formats + + def _formatInterpolations( + self, + unnamedPercent: list[str], + namedPercent: set[str], + formats: set[str], + ) -> str: + """Format the interpolations for display in an error message. + :param unnamedPercent: The unnamed percent interpolations. + :param namedPercent: The named percent interpolations. + :param formats: The brace format interpolations. + """ + out: list[str] = [] + if unnamedPercent: + out.append(f"unnamed percent interpolations in this order: {unnamedPercent}") + if namedPercent: + out.append(f"these named percent interpolations: {namedPercent}") + if formats: + out.append(f"these brace format interpolations: {formats}") + if not out: + return "no interpolations" + return "\n\tAnd ".join(out) + + def _checkMessage(self) -> None: + idUnnamedPercent, idNamedPercent, idFormats = self._getInterpolations(self._msgid) + if not self._msgstr[-1]: + return + strUnnamedPercent, strNamedPercent, strFormats = self._getInterpolations(self._msgstr[-1]) + error = False + alerts = [] + if idUnnamedPercent != strUnnamedPercent: + if idUnnamedPercent: + alerts.append("unnamed percent interpolations differ") + error = True + else: + alerts.append("unexpected presence of unnamed percent interpolations") + if idNamedPercent - strNamedPercent: + alerts.append("missing named percent interpolation") + error = True + if strNamedPercent - idNamedPercent: + if idNamedPercent: + alerts.append("extra named percent interpolation") + error = True + else: + alerts.append("unexpected presence of named percent interpolations") + if idFormats - strFormats: + alerts.append("missing brace format interpolation") + error = True + if strFormats - idFormats: + if idFormats: + alerts.append("extra brace format interpolation") + error = True + else: + alerts.append("unexpected presence of brace format interpolations") + if alerts: + self._messageAlert( + f"{', '.join(alerts)}\n" + f"Expected: {self._formatInterpolations(idUnnamedPercent, idNamedPercent, idFormats)}\n" + f"Got: {self._formatInterpolations(strUnnamedPercent, strNamedPercent, strFormats)}", + isError=error, + ) + + def getReport(self) -> str | None: + """Get a text report about any errors or warnings. + :return: The text or None if there were no problems. + """ + if not self.alerts: + return None + report = f"File {self._poPath}: " + if self.hasSyntaxError: + report += "syntax error" + else: + if self.errorCount: + msg = "error" if self.errorCount == 1 else "errors" + report += f"{self.errorCount} {msg}" + if self.warningCount: + if self.errorCount: + report += ", " + msg = "warning" if self.warningCount == 1 else "warnings" + report += f"{self.warningCount} {msg}" + report += "\n\n" + "\n\n".join(self.alerts) + return report + + +def checkPo(poFilePath: str) -> tuple[bool, str | None]: + """Check a po file for errors. + :param poFilePath: The path to the po file to check. + :return: + True if the file is okay or has warnings, False if there were fatal errors. + A report about the errors or warnings found, or None if there were no problems. + """ + c = _PoChecker(poFilePath) + report = None + if not c.check(): + report = c.getReport() + if report: + report = report.encode("cp1252", errors="backslashreplace").decode( + "utf-8", + errors="backslashreplace", + ) + return not bool(c.errorCount), report + + +def main(): + args = argparse.ArgumentParser() + commands = args.add_subparsers(title="commands", dest="command", required=True) + command_checkPo = commands.add_parser("checkPo", help="Check po files") + # Allow entering arbitrary po file paths, not just those in the source tree + command_checkPo.add_argument( + "poFilePaths", + help="Paths to the po file to check", + nargs="+", + ) + command_xliff2md = commands.add_parser("xliff2md", help="Convert xliff to markdown") + command_xliff2md.add_argument( + "-u", + "--untranslated", + help="Produce the untranslated markdown file", + action="store_true", + default=False, + ) + command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") + command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") + command_md2html = commands.add_parser("md2html", help="Convert markdown to html") + command_md2html.add_argument("-l", "--lang", help="Language code", action="store", default="en") + command_md2html.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + command_md2html.add_argument("mdPath", help="Path to the markdown file") + command_md2html.add_argument("htmlPath", help="Path to the resulting html file") + command_xliff2html = commands.add_parser("xliff2html", help="Convert xliff to html") + command_xliff2html.add_argument("-l", "--lang", help="Language code", action="store", required=False) + command_xliff2html.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + command_xliff2html.add_argument( + "-u", + "--untranslated", + help="Produce the untranslated markdown file", + action="store_true", + default=False, + ) + command_xliff2html.add_argument("xliffPath", help="Path to the xliff file") + command_xliff2html.add_argument("htmlPath", help="Path to the resulting html file") + uploadSourceFileCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + uploadSourceFileCommand.add_argument( + "-f", + "--localFilePath", + help="The local path to the file.", + ) + getFilesCommand = commands.add_parser( + "getFiles", + help="Get files from Crowdin.", + ) + downloadTranslationFileCommand = commands.add_parser( + "downloadTranslationFile", + help="Download a translation file from Crowdin.", + ) + downloadTranslationFileCommand.add_argument( + "language", + help="The language code to download the translation for.", + ) + downloadTranslationFileCommand.add_argument( + "crowdinFilePath", + help="The Crowdin file path", + ) + downloadTranslationFileCommand.add_argument( + "localFilePath", + nargs="?", + default=None, + help="The path to save the local file. If not provided, the Crowdin file path will be used.", + ) + uploadTranslationFileCommand = commands.add_parser( + "uploadTranslationFile", + help="Upload a translation file to Crowdin.", + ) + uploadTranslationFileCommand.add_argument( + "-o", + "--old", + help="Path to the old unchanged xliff file. If provided, only new or changed translations will be uploaded.", + default=None, + ) + uploadTranslationFileCommand.add_argument( + "language", + help="The language code to upload the translation for.", + ) + uploadTranslationFileCommand.add_argument( + "crowdinFilePath", + help="The Crowdin file path", + ) + uploadTranslationFileCommand.add_argument( + "localFilePath", + nargs="?", + default=None, + help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", + ) + + exportTranslationsCommand = commands.add_parser( + "exportTranslations", + help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", + ) + exportTranslationsCommand.add_argument( + "-o", + "--output", + help="Directory to save translation files", + required=True, + ) + exportTranslationsCommand.add_argument( + "-l", + "--language", + help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", + default=None, + ) + + args = args.parse_args() + match args.command: + case "xliff2md": + markdownTranslate.generateMarkdown( + xliffPath=args.xliffPath, + outputPath=args.mdPath, + translated=not args.untranslated, + ) + case "md2html": + md2html.main(source=args.mdPath, dest=args.htmlPath, lang=args.lang, docType=args.docType) + case "xliff2html": + lang = args.lang or fetchLanguageFromXliff(args.xliffPath, source=args.untranslated) + temp_mdFile = tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") + temp_mdFile.close() + try: + markdownTranslate.generateMarkdown( + xliffPath=args.xliffPath, + outputPath=temp_mdFile.name, + translated=not args.untranslated, + ) + md2html.main(source=temp_mdFile.name, dest=args.htmlPath, lang=lang, docType=args.docType) + finally: + os.remove(temp_mdFile.name) + case "uploadSourceFile": + uploadSourceFile(args.localFilePath) + case "getFiles": + getFiles() + case "downloadTranslationFile": + localFilePath = args.localFilePath or args.crowdinFilePath + downloadTranslationFile(args.crowdinFilePath, localFilePath, args.language) + if args.crowdinFilePath.endswith(".xliff"): + preprocessXliff(localFilePath, localFilePath) + elif localFilePath.endswith(".po"): + success, report = checkPo(localFilePath) + if report: + print(report) + if not success: + print(f"\nWarning: Po file {localFilePath} has fatal errors.") + case "checkPo": + poFilePaths = args.poFilePaths + badFilePaths: list[str] = [] + for poFilePath in poFilePaths: + success, report = checkPo(poFilePath) + if report: + print(report) + if not success: + badFilePaths.append(poFilePath) + if badFilePaths: + print(f"\nOne or more po files had fatal errors: {', '.join(badFilePaths)}") + sys.exit(1) + case "uploadTranslationFile": + localFilePath = args.localFilePath or args.crowdinFilePath + needsDelete = False + if args.crowdinFilePath.endswith(".xliff"): + tmp = tempfile.NamedTemporaryFile(suffix=".xliff", delete=False, mode="w") + tmp.close() + shutil.copyfile(localFilePath, tmp.name) + stripXliff(tmp.name, tmp.name, args.old) + localFilePath = tmp.name + needsDelete = True + elif localFilePath.endswith(".po"): + success, report = checkPo(localFilePath) + if report: + print(report) + if not success: + print(f"\nPo file {localFilePath} has errors. Upload aborted.") + sys.exit(1) + uploadTranslationFile(args.crowdinFilePath, localFilePath, args.language) + if needsDelete: + os.remove(localFilePath) + case "exportTranslations": + exportTranslations(args.output, args.language) + case _: + raise ValueError(f"Unknown command {args.command}") + + +if __name__ == "__main__": + main() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py new file mode 100644 index 0000000..341ead6 --- /dev/null +++ b/_l10n/markdownTranslate.py @@ -0,0 +1,733 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import Generator +import tempfile +import os +import contextlib +import lxml.etree +import argparse +import uuid +import re +from itertools import zip_longest +from xml.sax.saxutils import escape as xmlEscape +import difflib +from dataclasses import dataclass +import subprocess + +RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" +re_kcTitle = re.compile(r"^()$") +re_kcSettingsSection = re.compile(r"^()$") +# Comments that span a single line in their entirety +re_comment = re.compile(r"^$") +re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") +re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") +re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") +re_hiddenHeaderRow = re.compile(r"^\|\s*\.\s*\{\.hideHeaderRow\}\s*(\|\s*\.\s*)*\|$") +re_postTableHeaderLine = re.compile(r"^(\|\s*-+\s*)+\|$") +re_tableRow = re.compile(r"^(\|)(.+)(\|)$") +re_translationID = re.compile(r"^(.*)\$\(ID:([0-9a-f-]+)\)(.*)$") + + +def prettyPathString(path: str) -> str: + cwd = os.getcwd() + if os.path.normcase(os.path.splitdrive(path)[0]) != os.path.normcase(os.path.splitdrive(cwd)[0]): + return path + return os.path.relpath(path, cwd) + + +@contextlib.contextmanager +def createAndDeleteTempFilePath_contextManager( + dir: str | None = None, + prefix: str | None = None, + suffix: str | None = None, +) -> Generator[str, None, None]: + """A context manager that creates a temporary file and deletes it when the context is exited""" + with tempfile.NamedTemporaryFile(dir=dir, prefix=prefix, suffix=suffix, delete=False) as tempFile: + tempFilePath = tempFile.name + tempFile.close() + yield tempFilePath + os.remove(tempFilePath) + + +def getLastCommitID(filePath: str) -> str: + # Run the git log command to get the last commit ID for the given file + result = subprocess.run( + ["git", "log", "-n", "1", "--pretty=format:%H", "--", filePath], + capture_output=True, + text=True, + check=True, + ) + commitID = result.stdout.strip() + if not re.match(r"[0-9a-f]{40}", commitID): + raise ValueError(f"Invalid commit ID: '{commitID}' for file '{filePath}'") + return commitID + + +def getGitDir() -> str: + # Run the git rev-parse command to get the root of the git directory + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + gitDir = result.stdout.strip() + if not os.path.isdir(gitDir): + raise ValueError(f"Invalid git directory: '{gitDir}'") + return gitDir + + +def getRawGithubURLForPath(filePath: str) -> str: + gitDirPath = getGitDir() + commitID = getLastCommitID(filePath) + relativePath = os.path.relpath(os.path.abspath(filePath), gitDirPath) + relativePath = relativePath.replace("\\", "/") + return f"{RAW_GITHUB_REPO_URL}/{commitID}/{relativePath}" + + +def skeletonizeLine(mdLine: str) -> str | None: + prefix = "" + suffix = "" + if ( + mdLine.isspace() + or mdLine.strip() == "[TOC]" + or re_hiddenHeaderRow.match(mdLine) + or re_postTableHeaderLine.match(mdLine) + ): + return None + elif m := re_heading.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_bullet.match(mdLine): + prefix, content = m.groups() + elif m := re_number.match(mdLine): + prefix, content = m.groups() + elif m := re_tableRow.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_kcTitle.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_kcSettingsSection.match(mdLine): + prefix, content, suffix = m.groups() + elif re_comment.match(mdLine): + return None + ID = str(uuid.uuid4()) + return f"{prefix}$(ID:{ID}){suffix}\n" + + +@dataclass +class Result_generateSkeleton: + numTotalLines: int = 0 + numTranslationPlaceholders: int = 0 + + +def generateSkeleton(mdPath: str, outputPath: str) -> Result_generateSkeleton: + print(f"Generating skeleton file {prettyPathString(outputPath)} from {prettyPathString(mdPath)}...") + res = Result_generateSkeleton() + with ( + open(mdPath, "r", encoding="utf8") as mdFile, + open(outputPath, "w", encoding="utf8", newline="") as outputFile, + ): + for mdLine in mdFile.readlines(): + res.numTotalLines += 1 + skelLine = skeletonizeLine(mdLine) + if skelLine: + res.numTranslationPlaceholders += 1 + else: + skelLine = mdLine + outputFile.write(skelLine) + print( + f"Generated skeleton file with {res.numTotalLines} total lines and {res.numTranslationPlaceholders} translation placeholders", + ) + return res + + +@dataclass +class Result_updateSkeleton: + numAddedLines: int = 0 + numAddedTranslationPlaceholders: int = 0 + numRemovedLines: int = 0 + numRemovedTranslationPlaceholders: int = 0 + numUnchangedLines: int = 0 + numUnchangedTranslationPlaceholders: int = 0 + + +def extractSkeleton(xliffPath: str, outputPath: str): + print(f"Extracting skeleton from {prettyPathString(xliffPath)} to {prettyPathString(outputPath)}...") + with contextlib.ExitStack() as stack: + outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + outputFile.write(skeletonContent) + print(f"Extracted skeleton to {prettyPathString(outputPath)}") + + +def updateSkeleton( + origMdPath: str, + newMdPath: str, + origSkelPath: str, + outputPath: str, +) -> Result_updateSkeleton: + print( + f"Creating updated skeleton file {prettyPathString(outputPath)} from {prettyPathString(origSkelPath)} with changes from {prettyPathString(origMdPath)} to {prettyPathString(newMdPath)}...", + ) + res = Result_updateSkeleton() + with contextlib.ExitStack() as stack: + origMdFile = stack.enter_context(open(origMdPath, "r", encoding="utf8")) + newMdFile = stack.enter_context(open(newMdPath, "r", encoding="utf8")) + origSkelFile = stack.enter_context(open(origSkelPath, "r", encoding="utf8")) + outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) + mdDiff = difflib.ndiff(origMdFile.readlines(), newMdFile.readlines()) + origSkelLines = iter(origSkelFile.readlines()) + for mdDiffLine in mdDiff: + if mdDiffLine.startswith("?"): + continue + if mdDiffLine.startswith(" "): + res.numUnchangedLines += 1 + skelLine = next(origSkelLines) + if re_translationID.match(skelLine): + res.numUnchangedTranslationPlaceholders += 1 + outputFile.write(skelLine) + elif mdDiffLine.startswith("+"): + res.numAddedLines += 1 + skelLine = skeletonizeLine(mdDiffLine[2:]) + if skelLine: + res.numAddedTranslationPlaceholders += 1 + else: + skelLine = mdDiffLine[2:] + outputFile.write(skelLine) + elif mdDiffLine.startswith("-"): + res.numRemovedLines += 1 + origSkelLine = next(origSkelLines) + if re_translationID.match(origSkelLine): + res.numRemovedTranslationPlaceholders += 1 + else: + raise ValueError(f"Unexpected diff line: {mdDiffLine}") + print( + f"Updated skeleton file with {res.numAddedLines} added lines " + f"({res.numAddedTranslationPlaceholders} translation placeholders), " + f"{res.numRemovedLines} removed lines ({res.numRemovedTranslationPlaceholders} translation placeholders), " + f"and {res.numUnchangedLines} unchanged lines ({res.numUnchangedTranslationPlaceholders} translation placeholders)", + ) + return res + + +@dataclass +class Result_generateXliff: + numTranslatableStrings: int = 0 + + +def generateXliff( + mdPath: str, + outputPath: str, + skelPath: str | None = None, +) -> Result_generateXliff: + # If a skeleton file is not provided, first generate one + with contextlib.ExitStack() as stack: + if not skelPath: + skelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager( + dir=os.path.dirname(outputPath), + prefix=os.path.basename(mdPath), + suffix=".skel", + ), + ) + generateSkeleton(mdPath=mdPath, outputPath=skelPath) + with open(skelPath, "r", encoding="utf8") as skelFile: + skelContent = skelFile.read() + res = Result_generateXliff() + print( + f"Generating xliff file {prettyPathString(outputPath)} from {prettyPathString(mdPath)} and {prettyPathString(skelPath)}...", + ) + with contextlib.ExitStack() as stack: + mdFile = stack.enter_context(open(mdPath, "r", encoding="utf8")) + outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) + fileID = os.path.basename(mdPath) + mdUri = getRawGithubURLForPath(mdPath) + print(f"Including Github raw URL: {mdUri}") + outputFile.write( + '\n' + f'\n' + f'\n', + ) + outputFile.write(f"\n{xmlEscape(skelContent)}\n\n") + res.numTranslatableStrings = 0 + for lineNo, (mdLine, skelLine) in enumerate( + zip_longest(mdFile.readlines(), skelContent.splitlines(keepends=True)), + start=1, + ): + mdLine = mdLine.rstrip() + skelLine = skelLine.rstrip() + if m := re_translationID.match(skelLine): + res.numTranslatableStrings += 1 + prefix, ID, suffix = m.groups() + if prefix and not mdLine.startswith(prefix): + raise ValueError(f'Line {lineNo}: does not start with "{prefix}", {mdLine=}, {skelLine=}') + if suffix and not mdLine.endswith(suffix): + raise ValueError(f'Line {lineNo}: does not end with "{suffix}", {mdLine=}, {skelLine=}') + source = mdLine[len(prefix) : len(mdLine) - len(suffix)] + outputFile.write( + f'\n\nline: {lineNo + 1}\n', + ) + if prefix: + outputFile.write(f'prefix: {xmlEscape(prefix)}\n') + if suffix: + outputFile.write(f'suffix: {xmlEscape(suffix)}\n') + outputFile.write( + "\n" + f"\n" + f"{xmlEscape(source)}\n" + "\n" + "\n", # fmt: skip + ) + else: + if mdLine != skelLine: + raise ValueError(f"Line {lineNo}: {mdLine=} does not match {skelLine=}") + outputFile.write("\n") + print(f"Generated xliff file with {res.numTranslatableStrings} translatable strings") + return res + + +@dataclass +class Result_translateXliff: + numTranslatedStrings: int = 0 + + +def updateXliff( + xliffPath: str, + mdPath: str, + outputPath: str, +): + # uses generateMarkdown, extractSkeleton, updateSkeleton, and generateXliff to generate an updated xliff file. + outputDir = os.path.dirname(outputPath) + print( + f"Generating updated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} and {prettyPathString(mdPath)}...", + ) + with contextlib.ExitStack() as stack: + origMdPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="generated_", suffix=".md"), + ) + generateMarkdown(xliffPath=xliffPath, outputPath=origMdPath, translated=False) + origSkelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="extracted_", suffix=".skel"), + ) + extractSkeleton(xliffPath=xliffPath, outputPath=origSkelPath) + updatedSkelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="updated_", suffix=".skel"), + ) + updateSkeleton( + origMdPath=origMdPath, + newMdPath=mdPath, + origSkelPath=origSkelPath, + outputPath=updatedSkelPath, + ) + generateXliff( + mdPath=mdPath, + skelPath=updatedSkelPath, + outputPath=outputPath, + ) + print(f"Generated updated xliff file {prettyPathString(outputPath)}") + + +def translateXliff( + xliffPath: str, + lang: str, + pretranslatedMdPath: str, + outputPath: str, + allowBadAnchors: bool = False, +) -> Result_translateXliff: + print( + f"Creating {lang} translated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} using {prettyPathString(pretranslatedMdPath)}...", + ) + res = Result_translateXliff() + with contextlib.ExitStack() as stack: + pretranslatedMdFile = stack.enter_context(open(pretranslatedMdPath, "r", encoding="utf8")) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + xliffRoot.set("trgLang", lang) + skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + for lineNo, (skelLine, pretranslatedLine) in enumerate( + zip_longest(skeletonContent.splitlines(), pretranslatedMdFile.readlines()), + start=1, + ): + skelLine = skelLine.rstrip() + pretranslatedLine = pretranslatedLine.rstrip() + if m := re_translationID.match(skelLine): + prefix, ID, suffix = m.groups() + if prefix and not pretranslatedLine.startswith(prefix): + raise ValueError( + f'Line {lineNo} of translation does not start with "{prefix}", {pretranslatedLine=}, {skelLine=}', + ) + if suffix and not pretranslatedLine.endswith(suffix): + if allowBadAnchors and (m := re_heading.match(pretranslatedLine)): + print(f"Warning: ignoring bad anchor in line {lineNo}: {pretranslatedLine}") + suffix = m.group(3) + if suffix and not pretranslatedLine.endswith(suffix): + raise ValueError( + f'Line {lineNo} of translation: does not end with "{suffix}", {pretranslatedLine=}, {skelLine=}', + ) + translation = pretranslatedLine[len(prefix) : len(pretranslatedLine) - len(suffix)] + try: + unit = xliffRoot.find(f'./xliff:file/xliff:unit[@id="{ID}"]', namespaces=namespace) + if unit is not None: + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is not None: + target = lxml.etree.Element("target") + target.text = translation + target.tail = "\n" + segment.append(target) + res.numTranslatedStrings += 1 + else: + raise ValueError(f"No segment found for unit {ID}") + else: + raise ValueError(f"Cannot locate Unit {ID} in xliff file") + except Exception as e: + e.add_note(f"Line {lineNo}: {pretranslatedLine=}, {skelLine=}") + raise + elif skelLine != pretranslatedLine: + raise ValueError( + f"Line {lineNo}: pretranslated line {pretranslatedLine!r}, does not match skeleton line {skelLine!r}", + ) + xliff.write(outputPath, encoding="utf8", xml_declaration=True) + print(f"Translated xliff file with {res.numTranslatedStrings} translated strings") + return res + + +@dataclass +class Result_generateMarkdown: + numTotalLines = 0 + numTranslatableStrings = 0 + numTranslatedStrings = 0 + numBadTranslationStrings = 0 + + +def generateMarkdown(xliffPath: str, outputPath: str, translated: bool = True) -> Result_generateMarkdown: + print(f"Generating markdown file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)}...") + res = Result_generateMarkdown() + with contextlib.ExitStack() as stack: + outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + for lineNum, line in enumerate(skeletonContent.splitlines(keepends=True), 1): + res.numTotalLines += 1 + if m := re_translationID.match(line): + prefix, ID, suffix = m.groups() + res.numTranslatableStrings += 1 + unit = xliffRoot.find(f'./xliff:file/xliff:unit[@id="{ID}"]', namespaces=namespace) + if unit is None: + raise ValueError(f"Cannot locate Unit {ID} in xliff file") + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + raise ValueError(f"No segment found for unit {ID}") + source = segment.find("./xliff:source", namespaces=namespace) + if source is None: + raise ValueError(f"No source found for unit {ID}") + translation = "" + if translated: + target = segment.find("./xliff:target", namespaces=namespace) + if target is not None: + targetText = target.text + if targetText: + translation = targetText + # Crowdin treats empty targets () as a literal translation. + # Filter out such strings and count them as bad translations. + if translation in ( + "", + "<target/>", + "", + "<target></target>", + ): + res.numBadTranslationStrings += 1 + translation = "" + else: + res.numTranslatedStrings += 1 + # If we have no translation, use the source text + if not translation: + sourceText = source.text + if sourceText is None: + raise ValueError(f"No source text found for unit {ID}") + translation = sourceText + outputFile.write(f"{prefix}{translation}{suffix}\n") + else: + outputFile.write(line) + print( + f"Generated markdown file with {res.numTotalLines} total lines, {res.numTranslatableStrings} translatable strings, and {res.numTranslatedStrings} translated strings. Ignoring {res.numBadTranslationStrings} bad translated strings", + ) + return res + + +def ensureMarkdownFilesMatch(path1: str, path2: str, allowBadAnchors: bool = False): + print(f"Ensuring files {prettyPathString(path1)} and {prettyPathString(path2)} match...") + with contextlib.ExitStack() as stack: + file1 = stack.enter_context(open(path1, "r", encoding="utf8")) + file2 = stack.enter_context(open(path2, "r", encoding="utf8")) + for lineNo, (line1, line2) in enumerate(zip_longest(file1.readlines(), file2.readlines()), start=1): + line1 = line1.rstrip() + line2 = line2.rstrip() + if line1 != line2: + if ( + re_postTableHeaderLine.match(line1) + and re_postTableHeaderLine.match(line2) + and line1.count("|") == line2.count("|") + ): + print( + f"Warning: ignoring cell padding of post table header line at line {lineNo}: {line1}, {line2}", + ) + continue + if ( + re_hiddenHeaderRow.match(line1) + and re_hiddenHeaderRow.match(line2) + and line1.count("|") == line2.count("|") + ): + print( + f"Warning: ignoring cell padding of hidden header row at line {lineNo}: {line1}, {line2}", + ) + continue + if allowBadAnchors and (m1 := re_heading.match(line1)) and (m2 := re_heading.match(line2)): + print(f"Warning: ignoring bad anchor in headings at line {lineNo}: {line1}, {line2}") + line1 = m1.group(1) + m1.group(2) + line2 = m2.group(1) + m2.group(2) + if line1 != line2: + raise ValueError(f"Files do not match at line {lineNo}: {line1=} {line2=}") + print("Files match") + + +def markdownTranslateCommand(command: str, *args): + print(f"Running markdownTranslate command: {command} {' '.join(args)}") + subprocess.run(["python", __file__, command, *args], check=True) + + +def pretranslateAllPossibleLanguages(langsDir: str, mdBaseName: str): + # This function walks through all language directories in the given directory, skipping en (English) and translates the English xlif and skel file along with the lang's pretranslated md file + enXliffPath = os.path.join(langsDir, "en", f"{mdBaseName}.xliff") + if not os.path.exists(enXliffPath): + raise ValueError(f"English xliff file {enXliffPath} does not exist") + allLangs = set() + succeededLangs = set() + skippedLangs = set() + for langDir in os.listdir(langsDir): + if langDir == "en": + continue + langDirPath = os.path.join(langsDir, langDir) + if not os.path.isdir(langDirPath): + continue + langPretranslatedMdPath = os.path.join(langDirPath, f"{mdBaseName}.md") + if not os.path.exists(langPretranslatedMdPath): + continue + allLangs.add(langDir) + langXliffPath = os.path.join(langDirPath, f"{mdBaseName}.xliff") + if os.path.exists(langXliffPath): + print(f"Skipping {langDir} as the xliff file already exists") + skippedLangs.add(langDir) + continue + try: + translateXliff( + xliffPath=enXliffPath, + lang=langDir, + pretranslatedMdPath=langPretranslatedMdPath, + outputPath=langXliffPath, + allowBadAnchors=True, + ) + except Exception as e: + print(f"Failed to translate {langDir}: {e}") + continue + rebuiltLangMdPath = os.path.join(langDirPath, f"rebuilt_{mdBaseName}.md") + try: + generateMarkdown( + xliffPath=langXliffPath, + outputPath=rebuiltLangMdPath, + ) + except Exception as e: + print(f"Failed to rebuild {langDir} markdown: {e}") + os.remove(langXliffPath) + continue + try: + ensureMarkdownFilesMatch(rebuiltLangMdPath, langPretranslatedMdPath, allowBadAnchors=True) + except Exception as e: + print(f"Rebuilt {langDir} markdown does not match pretranslated markdown: {e}") + os.remove(langXliffPath) + continue + os.remove(rebuiltLangMdPath) + print(f"Successfully pretranslated {langDir}") + succeededLangs.add(langDir) + if len(skippedLangs) > 0: + print(f"Skipped {len(skippedLangs)} languages already pretranslated.") + print(f"Pretranslated {len(succeededLangs)} out of {len(allLangs) - len(skippedLangs)} languages.") + + +if __name__ == "__main__": + mainParser = argparse.ArgumentParser() + commandParser = mainParser.add_subparsers(title="commands", dest="command", required=True) + generateXliffParser = commandParser.add_parser("generateXliff") + generateXliffParser.add_argument( + "-m", + "--markdown", + dest="md", + type=str, + required=True, + help="The markdown file to generate the xliff file for", + ) + generateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the xliff file to", + ) + updateXliffParser = commandParser.add_parser("updateXliff") + updateXliffParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The original xliff file", + ) + updateXliffParser.add_argument( + "-m", + "--newMarkdown", + dest="md", + type=str, + required=True, + help="The new markdown file", + ) + updateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the updated xliff to", + ) + translateXliffParser = commandParser.add_parser("translateXliff") + translateXliffParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The xliff file to translate", + ) + translateXliffParser.add_argument( + "-l", + "--lang", + dest="lang", + type=str, + required=True, + help="The language to translate to", + ) + translateXliffParser.add_argument( + "-p", + "--pretranslatedMarkdown", + dest="pretranslatedMd", + type=str, + required=True, + help="The pretranslated markdown file to use", + ) + translateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the translated xliff file to", + ) + generateMarkdownParser = commandParser.add_parser("generateMarkdown") + generateMarkdownParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The xliff file to generate the markdown file for", + ) + generateMarkdownParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the markdown file to", + ) + generateMarkdownParser.add_argument( + "-u", + "--untranslated", + dest="translated", + action="store_false", + help="Generate the markdown file with the untranslated strings", + ) + ensureMarkdownFilesMatchParser = commandParser.add_parser("ensureMarkdownFilesMatch") + ensureMarkdownFilesMatchParser.add_argument( + dest="path1", + type=str, + help="The first markdown file", + ) + ensureMarkdownFilesMatchParser.add_argument( + dest="path2", + type=str, + help="The second markdown file", + ) + pretranslateLangsParser = commandParser.add_parser("pretranslateLangs") + pretranslateLangsParser.add_argument( + "-d", + "--langs-dir", + dest="langsDir", + type=str, + required=True, + help="The directory containing the language directories", + ) + pretranslateLangsParser.add_argument( + "-b", + "--md-base-name", + dest="mdBaseName", + type=str, + required=True, + help="The base name of the markdown files to pretranslate", + ) + args = mainParser.parse_args() + match args.command: + case "generateXliff": + generateXliff(mdPath=args.md, outputPath=args.output) + case "updateXliff": + updateXliff( + xliffPath=args.xliff, + mdPath=args.md, + outputPath=args.output, + ) + case "generateMarkdown": + generateMarkdown(xliffPath=args.xliff, outputPath=args.output, translated=args.translated) + case "translateXliff": + translateXliff( + xliffPath=args.xliff, + lang=args.lang, + pretranslatedMdPath=args.pretranslatedMd, + outputPath=args.output, + ) + case "pretranslateLangs": + pretranslateAllPossibleLanguages(langsDir=args.langsDir, mdBaseName=args.mdBaseName) + case "ensureMarkdownFilesMatch": + ensureMarkdownFilesMatch(path1=args.path1, path2=args.path2) + case _: + raise ValueError(f"Unknown command: {args.command}") diff --git a/_l10n/md2html.py b/_l10n/md2html.py new file mode 100644 index 0000000..01acab0 --- /dev/null +++ b/_l10n/md2html.py @@ -0,0 +1,197 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023-2024 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +import argparse +from copy import deepcopy +import io +import re +import shutil + +DEFAULT_EXTENSIONS = frozenset( + { + # Supports tables, HTML mixed with markdown, code blocks, custom attributes and more + "markdown.extensions.extra", + # Allows TOC with [TOC]" + "markdown.extensions.toc", + # Makes list behaviour better, including 2 space indents by default + "mdx_truly_sane_lists", + # External links will open in a new tab, and title will be set to the link text + "markdown_link_attr_modifier", + # Adds links to GitHub authors, issues and PRs + "mdx_gh_links", + }, +) + +EXTENSIONS_CONFIG = { + "markdown_link_attr_modifier": { + "new_tab": "external_only", + "auto_title": "on", + }, + "mdx_gh_links": { + "user": "nvaccess", + "repo": "nvda", + }, +} + +RTL_LANG_CODES = frozenset({"ar", "fa", "he"}) + +HTML_HEADERS = """ + + + + +{title} + + + +{extraStylesheet} + + +""".strip() + + +def _getTitle(mdBuffer: io.StringIO, isKeyCommands: bool = False) -> str: + if isKeyCommands: + TITLE_RE = re.compile(r"^$") + # Make next read at start of buffer + mdBuffer.seek(0) + for line in mdBuffer.readlines(): + match = TITLE_RE.match(line.strip()) + if match: + return match.group(1) + + raise ValueError("No KC:title command found in userGuide.md") + + else: + # Make next read at start of buffer + mdBuffer.seek(0) + # Remove heading hashes and trailing whitespace to get the tab title + title = mdBuffer.readline().strip().lstrip("# ") + + return title + + +def _createAttributeFilter() -> dict[str, set[str]]: + # Create attribute filter exceptions for HTML sanitization + import nh3 + + allowedAttributes: dict[str, set[str]] = deepcopy(nh3.ALLOWED_ATTRIBUTES) + + attributesWithAnchors = {"h1", "h2", "h3", "h4", "h5", "h6", "td"} + attributesWithClass = {"div", "span", "a", "th", "td"} + + # Allow IDs for anchors + for attr in attributesWithAnchors: + if attr not in allowedAttributes: + allowedAttributes[attr] = set() + allowedAttributes[attr].add("id") + + # Allow class for styling + for attr in attributesWithClass: + if attr not in allowedAttributes: + allowedAttributes[attr] = set() + allowedAttributes[attr].add("class") + + # link rel and target is set by markdown_link_attr_modifier + allowedAttributes["a"].update({"rel", "target"}) + + return allowedAttributes + + +ALLOWED_ATTRIBUTES = _createAttributeFilter() + + +def _generateSanitizedHTML(md: str, isKeyCommands: bool = False) -> str: + import markdown + import nh3 + + extensions = set(DEFAULT_EXTENSIONS) + if isKeyCommands: + from keyCommandsDoc import KeyCommandsExtension + + extensions.add(KeyCommandsExtension()) + + htmlOutput = markdown.markdown( + text=md, + extensions=extensions, + extension_configs=EXTENSIONS_CONFIG, + ) + + # Sanitize html output from markdown to prevent XSS from translators + htmlOutput = nh3.clean( + htmlOutput, + attributes=ALLOWED_ATTRIBUTES, + # link rel is handled by markdown_link_attr_modifier + link_rel=None, + # Keep key command comments and similar + strip_comments=False, + ) + + return htmlOutput + + +def main(source: str, dest: str, lang: str = "en", docType: str | None = None): + print(f"Converting {docType or 'document'} ({lang=}) at {source} to {dest}") + isUserGuide = docType == "userGuide" + isDevGuide = docType == "developerGuide" + isChanges = docType == "changes" + isKeyCommands = docType == "keyCommands" + if docType and not any([isUserGuide, isDevGuide, isChanges, isKeyCommands]): + raise ValueError(f"Unknown docType {docType}") + with open(source, "r", encoding="utf-8") as mdFile: + mdStr = mdFile.read() + + with io.StringIO() as mdBuffer: + mdBuffer.write(mdStr) + title = _getTitle(mdBuffer, isKeyCommands) + + if isUserGuide or isDevGuide: + extraStylesheet = '' + elif isChanges or isKeyCommands: + extraStylesheet = "" + else: + raise ValueError(f"Unknown target type for {dest}") + + htmlBuffer = io.StringIO() + htmlBuffer.write( + HTML_HEADERS.format( + lang=lang, + dir="rtl" if lang in RTL_LANG_CODES else "ltr", + title=title, + extraStylesheet=extraStylesheet, + ), + ) + + htmlOutput = _generateSanitizedHTML(mdStr, isKeyCommands) + # Make next write append at end of buffer + htmlBuffer.seek(0, io.SEEK_END) + htmlBuffer.write(htmlOutput) + + # Make next write append at end of buffer + htmlBuffer.seek(0, io.SEEK_END) + htmlBuffer.write("\n\n\n") + + with open(dest, "w", encoding="utf-8") as targetFile: + # Make next read at start of buffer + htmlBuffer.seek(0) + shutil.copyfileobj(htmlBuffer, targetFile) + + htmlBuffer.close() + + +if __name__ == "__main__": + args = argparse.ArgumentParser() + args.add_argument("-l", "--lang", help="Language code", action="store", default="en") + args.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + args.add_argument("source", help="Path to the markdown file") + args.add_argument("dest", help="Path to the resulting html file") + args = args.parse_args() + main(source=args.source, dest=args.dest, lang=args.lang, docType=args.docType) From 46694309932d6713effc0c0c951535b8a2128986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 11:14:14 +0100 Subject: [PATCH 002/142] use a json file to store addonId, and use it to filter files to get Crowdin ID --- _l10n/l10nUtil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 00bee4c..2808258 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -23,7 +23,7 @@ CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -JSON_FILE = os.path.join(os.path.dirname(__file__), "files.json") +JSON_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") def fetchCrowdinAuthToken() -> str: From b18856045262e80c32815b055ad50e12d64aae0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 22:05:55 +0100 Subject: [PATCH 003/142] Try to get files just for the current add-on --- _l10n/l10nUtil.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 2808258..9480753 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2025 NV Access Limited. +# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -23,7 +23,9 @@ CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -JSON_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") def fetchCrowdinAuthToken() -> str: @@ -296,10 +298,14 @@ def uploadSourceFile(localFilePath: str): res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) -def getFiles() -> dict: +def getFiles() -> dict[str, str]: """Gets files from Crowdin, and write them to a json file.""" - res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, limit=500) + with open(METADATA_FILE, "R", encoding="utf-8") as jsonFile: + addonData = json.load(jsonFile) + addonId = addonData.get("addonId") + + res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) if res is None: raise ValueError("Getting files from Crowdin failed") dictionary = dict() @@ -309,8 +315,8 @@ def getFiles() -> dict: name = fileInfo["name"] id = fileInfo["id"] dictionary[name] = id - with open(JSON_FILE, "w", encoding="utf-8") as jsonFile: - json.dump(dictionary, jsonFile, ensure_ascii=False) + with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: + json.dump(dictionary, jsonFile, ensure_ascii=False) return dictionary @@ -321,7 +327,7 @@ def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: st :param localFilePath: The path to the local file to be uploaded :param language: The language code to upload the translation for """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(crowdinFilePath) if fileId is None: From 709261583343a4fb25a75ecfd1c4af81e7de22c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 22:32:53 +0100 Subject: [PATCH 004/142] Add workflow to export an add-on to Crowdin (authors would need to be addedwith dev role to Crowdin if they use a project not owned by them to upload source files) --- .github/workflows/exportAddonToCrowdin.yml | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/exportAddonToCrowdin.yml diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml new file mode 100644 index 0000000..4ab508b --- /dev/null +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -0,0 +1,96 @@ +name: Export add-on to Crowdin + +on: + workflow_dispatch: + inputs: + repo: + description: 'Repository name' + required: true + update: + description: 'true to update preexisting sources, false to add them from scratch' + type: boolean + + workflow_call: + inputs: + repo: + description: 'Repository name' + type: 'string' + required: true + update: + description: 'true to update preexisting sources, false to add them from scratch' + type: boolean + required: false +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout add-on + uses: actions/checkout@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install scons markdown + sudo apt update + sudo apt install gettext + - name: Build add-on and pot file + run: | + scons + scons pot + exportToCrowdin: + runs-on: ubuntu-latest + needs: build + permissions: + contents: write + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v7 + - name: Generate xliff + if: ${{ !inputs.update }} + run: | + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o inputs.repo }}.xliff + - name: update xliff + if: ${{ inputs.update }} + run: | + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ inputs.repo }}.xliff -m ${{ inputs.repo }}.md -o ${{ inputs.repo }}.xliff.temp + mv ${{ inputs.repo }}.xliff.temp ${{ inputs.repo }}.xliff + fi + - name: Upload to Crowdin + if: ${{ !inputs.update }} + run: | + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.pot + env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + - name: Update sources + if: ${{ inputs.update }} + run: | + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.xliff + env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + - name: Commit and push json file + id: commit + run: | + git config --local user.name github-actions + git config --local user.email github-actions@github.com + git status + git add _l10n/l10n.json + if git diff --staged --quiet; then + echo "Nothing added to commit." + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + git commit -m "Update Crowdin file ids" + git push + fi From e89640d95d7fa2c93e671584c9ec1cb0efc9ec96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 25 Nov 2025 05:36:33 +0100 Subject: [PATCH 005/142] Use buildVars, not metadata.json file --- _l10n/l10nUtil.py | 51 ++--------------------------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 9480753..e7feef2 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -19,12 +19,11 @@ import zipfile import time import json +from .. import buildVars CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") -METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") @@ -301,9 +300,7 @@ def uploadSourceFile(localFilePath: str): def getFiles() -> dict[str, str]: """Gets files from Crowdin, and write them to a json file.""" - with open(METADATA_FILE, "R", encoding="utf-8") as jsonFile: - addonData = json.load(jsonFile) - addonId = addonData.get("addonId") + addonId = buildVars.addon_info["addon_name"] res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) if res is None: @@ -802,35 +799,6 @@ def main(): ) command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - command_md2html = commands.add_parser("md2html", help="Convert markdown to html") - command_md2html.add_argument("-l", "--lang", help="Language code", action="store", default="en") - command_md2html.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - command_md2html.add_argument("mdPath", help="Path to the markdown file") - command_md2html.add_argument("htmlPath", help="Path to the resulting html file") - command_xliff2html = commands.add_parser("xliff2html", help="Convert xliff to html") - command_xliff2html.add_argument("-l", "--lang", help="Language code", action="store", required=False) - command_xliff2html.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - command_xliff2html.add_argument( - "-u", - "--untranslated", - help="Produce the untranslated markdown file", - action="store_true", - default=False, - ) - command_xliff2html.add_argument("xliffPath", help="Path to the xliff file") - command_xliff2html.add_argument("htmlPath", help="Path to the resulting html file") uploadSourceFileCommand = commands.add_parser( "uploadSourceFile", help="Upload a source file to Crowdin.", @@ -912,21 +880,6 @@ def main(): outputPath=args.mdPath, translated=not args.untranslated, ) - case "md2html": - md2html.main(source=args.mdPath, dest=args.htmlPath, lang=args.lang, docType=args.docType) - case "xliff2html": - lang = args.lang or fetchLanguageFromXliff(args.xliffPath, source=args.untranslated) - temp_mdFile = tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") - temp_mdFile.close() - try: - markdownTranslate.generateMarkdown( - xliffPath=args.xliffPath, - outputPath=temp_mdFile.name, - translated=not args.untranslated, - ) - md2html.main(source=temp_mdFile.name, dest=args.htmlPath, lang=lang, docType=args.docType) - finally: - os.remove(temp_mdFile.name) case "uploadSourceFile": uploadSourceFile(args.localFilePath) case "getFiles": From 4c7771b1f7fbeebc6c5bf3424f210bfcb2c99826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 16:43:35 +0100 Subject: [PATCH 006/142] Add userAccount to buildVars, and step to get addon-id to GitHub workflow to upload/update files in Crowdin --- .github/workflows/exportAddonToCrowdin.yml | 57 ++++++++++------------ _l10n/markdownTranslate.py | 8 ++- buildVars.py | 3 +- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4ab508b..40efcf3 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -1,21 +1,9 @@ name: Export add-on to Crowdin on: - workflow_dispatch: - inputs: - repo: - description: 'Repository name' - required: true - update: - description: 'true to update preexisting sources, false to add them from scratch' - type: boolean - workflow_call: + workflow_dispatch: inputs: - repo: - description: 'Repository name' - type: 'string' - required: true update: description: 'true to update preexisting sources, false to add them from scratch' type: boolean @@ -26,9 +14,15 @@ concurrency: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout add-on uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -39,43 +33,42 @@ jobs: run: | scons scons pot - exportToCrowdin: - runs-on: ubuntu-latest - needs: build - permissions: - contents: write - steps: - - name: Checkout main branch - uses: actions/checkout@v6 - - name: "Set up Python" - uses: actions/setup-python@v6 - with: - python-version-file: ".python-version" - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Get add-on id + id: getAddonId + shell: python + run: | + import os + import buildVars + addonId = buildVars.addon_info["addon_name"] + name = 'addonId' + value = addonId + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}={value}"") - name: Generate xliff if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o inputs.repo }}.xliff + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ inputs.repo }}.xliff -m ${{ inputs.repo }}.md -o ${{ inputs.repo }}.xliff.temp - mv ${{ inputs.repo }}.xliff.temp ${{ inputs.repo }}.xliff + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp + mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff fi - name: Upload to Crowdin if: ${{ !inputs.update }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.pot + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.pot env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - name: Update sources if: ${{ inputs.update }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.xliff + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.xliff env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index 341ead6..5af1d73 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -17,7 +17,13 @@ from dataclasses import dataclass import subprocess -RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" + +from .. import buildVars + +addonId = buildVars.addon_info["addonname"] +userAccount = buildVars.userAccount +RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{userAccount}/{addonId}" + re_kcTitle = re.compile(r"^()$") re_kcSettingsSection = re.compile(r"^()$") # Comments that span a single line in their entirety diff --git a/buildVars.py b/buildVars.py index c125fae..770946a 100644 --- a/buildVars.py +++ b/buildVars.py @@ -10,7 +10,8 @@ # which returns whatever is given to it as an argument. from site_scons.site_tools.NVDATool.utils import _ - +# The GitHub user account to generate xliff file for translations +userAccount: str | None = None # Add-on information variables addon_info = AddonInfo( # add-on Name/identifier, internal for NVDA From c529cee4d5c4db819c75b08f7f77ef2d4c70d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 21:36:41 +0100 Subject: [PATCH 007/142] Update files after testing exporting an add-on to Crowdin, needs refinements --- .github/workflows/exportAddonToCrowdin.yml | 7 +- _l10n/files.json | 2 +- _l10n/l10n.json | 1 + _l10n/l10nUtil.py | 62 +++++--- _l10n/markdownTranslate.py | 10 +- pyproject.toml | 176 ++------------------- 6 files changed, 60 insertions(+), 198 deletions(-) create mode 100644 _l10n/l10n.json diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 40efcf3..0b8dd9e 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -39,13 +39,14 @@ jobs: id: getAddonId shell: python run: | - import os + import os, sys + sys.path.insert(0, os.getcwd()) import buildVars addonId = buildVars.addon_info["addon_name"] name = 'addonId' value = addonId with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}"") + f.write(f"{name}={value}") - name: Generate xliff if: ${{ !inputs.update }} run: | @@ -53,7 +54,7 @@ jobs: - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff fi - name: Upload to Crowdin diff --git a/_l10n/files.json b/_l10n/files.json index 9264714..9e26dfe 100644 --- a/_l10n/files.json +++ b/_l10n/files.json @@ -1 +1 @@ -{"emoticons.pot": 176, "emoticons.xliff": 178, "goldwave.pot": 180, "goldwave.xliff": 182, "eMule.pot": 194, "enhancedTouchGestures.pot": 210, "resourceMonitor.pot": 214, "stationPlaylist.pot": 218, "cursorLocator.pot": 224, "pcKbBrl.pot": 228, "readFeeds.pot": 232, "reportSymbols.pot": 236, "urlShortener.pot": 240, "customNotifications.pot": 244, "readonlyProfiles.pot": 248, "enhancedAnnotations.pot": 252, "clipContentsDesigner.pot": 256, "clipContentsDesigner.xliff": 258, "controlUsageAssistant.pot": 260, "controlUsageAssistant.xliff": 262, "eMule.xliff": 264, "enhancedAnnotations.xliff": 266, "customNotifications.xliff": 268, "readonlyProfiles.xliff": 270, "urlShortener.xliff": 272, "reportSymbols.xliff": 274, "pcKbBrl.xliff": 276, "readFeeds.xliff": 278, "stationPlaylist.xliff": 282, "resourceMonitor.xliff": 284, "enhancedTouchGestures.xliff": 286, "rdAccess.pot": 288, "rdAccess.xliff": 290, "winMag.pot": 292, "winMag.xliff": 294, "charInfo.pot": 296, "charInfo.xliff": 298, "BMI.pot": 300, "BMI.xliff": 302, "tonysEnhancements.pot": 304, "tonysEnhancements.xliff": 306, "nvdaDevTestToolbox.pot": 308, "nvdaDevTestToolbox.xliff": 310, "easyTableNavigator.pot": 312, "easyTableNavigator.xliff": 314, "updateChannel.pot": 320, "updateChannel.xliff": 322, "instantTranslate.pot": 324, "instantTranslate.xliff": 326, "unicodeBrailleInput.pot": 328, "unicodeBrailleInput.xliff": 330, "columnsReview.pot": 332, "columnsReview.xliff": 334, "Access8Math.pot": 336, "Access8Math.xliff": 338, "systrayList.pot": 340, "systrayList.xliff": 342, "winWizard.pot": 344, "winWizard.xliff": 346, "speechLogger.pot": 348, "speechLogger.xliff": 350, "sayProductNameAndVersion.pot": 352, "sayProductNameAndVersion.xliff": 354, "objPad.pot": 356, "objPad.xliff": 358, "SentenceNav.pot": 360, "SentenceNav.xliff": 362, "wordNav.pot": 364, "wordNav.xliff": 366, "goldenCursor.pot": 368, "goldenCursor.xliff": 370, "MSEdgeDiscardAnnouncements.pot": 372, "MSEdgeDiscardAnnouncements.xliff": 374, "dayOfTheWeek.pot": 376, "dayOfTheWeek.xliff": 378, "outlookExtended.pot": 380, "outlookExtended.xliff": 382, "proxy.pot": 384, "proxy.xliff": 386, "searchWith.pot": 388, "searchWith.xliff": 390, "sayCurrentKeyboardLanguage.pot": 392, "sayCurrentKeyboardLanguage.xliff": 394, "robEnhancements.pot": 396, "robEnhancements.xliff": 398, "objWatcher.pot": 400, "objWatcher.xliff": 402, "mp3DirectCut.pot": 404, "mp3DirectCut.xliff": 406, "beepKeyboard.pot": 408, "beepKeyboard.xliff": 410, "numpadNavMode.pot": 412, "numpadNavMode.xliff": 414, "dropbox.pot": 416, "dropbox.xliff": 418, "reviewCursorCopier.pot": 420, "reviewCursorCopier.xliff": 422, "inputLock.pot": 424, "inputLock.xliff": 426, "debugHelper.pot": 428, "debugHelper.xliff": 430, "virtualRevision.pot": 432, "virtualRevision.xliff": 434, "cursorLocator.xliff": 436, "evtTracker.pot": 438, "evtTracker.xliff": 440} \ No newline at end of file +{} \ No newline at end of file diff --git a/_l10n/l10n.json b/_l10n/l10n.json new file mode 100644 index 0000000..abf3c01 --- /dev/null +++ b/_l10n/l10n.json @@ -0,0 +1 @@ +{"translateNvdaAddonsWithCrowdin.xliff": 442, "translateNvdaAddonsWithCrowdin.pot": 444} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index e7feef2..6cd4352 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -3,6 +3,9 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +import os, sys +sys.path.insert(0, os.getcwd()) + import crowdin_api as crowdin import tempfile import lxml.etree @@ -10,7 +13,6 @@ import shutil import argparse import markdownTranslate -import md2html import requests import codecs import re @@ -19,7 +21,8 @@ import zipfile import time import json -from .. import buildVars + +import buildVars CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 @@ -237,7 +240,7 @@ def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: :param localFilePath: The path to save the local file :param language: The language code to download the translation for """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(crowdinFilePath) if fileId is None: @@ -263,7 +266,7 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(localFilePath) if fileId is None: @@ -282,19 +285,31 @@ def uploadSourceFile(localFilePath: str): match fileId: case None: if os.path.splitext(filename)[1] == ".pot": - title=f"{os.path.splitext(filename)[0]} interface" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = ( + f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + ) else: - title=f"{os.path.splitext(filename)[0]} documentation" - exportPattern =f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" exportOptions = { - "exportPattern": exportPattern + "exportPattern": exportPattern, } print(f"Importing source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file(storageId=storageId, projectId=CROWDIN_PROJECT_ID, name=filename, title=title, exportOptions=exportOptions) + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) print("Done") case _: - res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) + res = getCrowdinClient().source_files.update_file( + fileId=fileId, + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + ) def getFiles() -> dict[str, str]: @@ -799,19 +814,6 @@ def main(): ) command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - uploadSourceFileCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - uploadSourceFileCommand.add_argument( - "-f", - "--localFilePath", - help="The local path to the file.", - ) - getFilesCommand = commands.add_parser( - "getFiles", - help="Get files from Crowdin.", - ) downloadTranslationFileCommand = commands.add_parser( "downloadTranslationFile", help="Download a translation file from Crowdin.", @@ -854,7 +856,15 @@ def main(): default=None, help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", ) - + uploadSourceFileCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + uploadSourceFileCommand.add_argument( + "-f", + "--localFilePath", + help="The local path to the file.", + ) exportTranslationsCommand = commands.add_parser( "exportTranslations", help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", @@ -869,7 +879,7 @@ def main(): "-l", "--language", help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", - default=None, + default=None, ) args = args.parse_args() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index 5af1d73..ee70eb7 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -17,17 +17,11 @@ from dataclasses import dataclass import subprocess - -from .. import buildVars - -addonId = buildVars.addon_info["addonname"] -userAccount = buildVars.userAccount -RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{userAccount}/{addonId}" - +RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" re_kcTitle = re.compile(r"^()$") re_kcSettingsSection = re.compile(r"^()$") # Comments that span a single line in their entirety -re_comment = re.compile(r"^$") +re_comment = re.compile(r"^$", re.DOTALL) re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") diff --git a/pyproject.toml b/pyproject.toml index 97189ac..44d0016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,161 +1,17 @@ -[tool.ruff] -line-length = 110 - -builtins = [ - # translation lookup - "_", - # translation lookup - "ngettext", - # translation lookup - "pgettext", - # translation lookup - "npgettext", +[project] +name = "addonTemplate" +version = "0.1.0" +description = "Addon template" +readme = "readme.md" +requires-python = ">=3.13" +dependencies = [ + "crowdin-api-client==1.21.0", + "lxml>=6.0.1", + "markdown>=3.9", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + "mdx-truly-sane-lists==1.3", + "nh3==0.2.19", + "requests>=2.32.5", + "SCons==4.10.1", ] - -include = [ - "*.py", - "sconstruct", -] - -exclude = [ - ".git", - "__pycache__", -] - -[tool.ruff.format] -indent-style = "tab" - -[tool.ruff.lint.mccabe] -max-complexity = 15 - -[tool.ruff.lint] -ignore = [ - # indentation contains tabs - "W191", -] - -[tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, -# so ignore F821. -"sconstruct" = ["F821"] - -[tool.pyright] -pythonPlatform = "Windows" -typeCheckingMode = "strict" - -include = [ - "**/*.py", -] - -exclude = [ - "sconstruct", - ".git", - "__pycache__", - # When excluding concrete paths relative to a directory, - # not matching multiple folders by name e.g. `__pycache__`, - # paths are relative to the configuration file. -] - -# Tell pyright where to load python code from -extraPaths = [ - "./addon", -] - -# General config -analyzeUnannotatedFunctions = true -deprecateTypingAliases = true - -# Stricter typing -strictParameterNoneValue = true -strictListInference = true -strictDictionaryInference = true -strictSetInference = true - -# Compliant rules -reportAbstractUsage = true -reportArgumentType = true -reportAssertAlwaysTrue = true -reportAssertTypeFailure = true -reportAssignmentType = true -reportAttributeAccessIssue = true -reportCallInDefaultInitializer = true -reportCallIssue = true -reportConstantRedefinition = true -reportDuplicateImport = true -reportFunctionMemberAccess = true -reportGeneralTypeIssues = true -reportImplicitOverride = true -reportImplicitStringConcatenation = true -reportImportCycles = true -reportIncompatibleMethodOverride = true -reportIncompatibleVariableOverride = true -reportIncompleteStub = true -reportInconsistentConstructor = true -reportInconsistentOverload = true -reportIndexIssue = true -reportInvalidStringEscapeSequence = true -reportInvalidStubStatement = true -reportInvalidTypeArguments = true -reportInvalidTypeForm = true -reportInvalidTypeVarUse = true -reportMatchNotExhaustive = true -reportMissingImports = true -reportMissingModuleSource = true -reportMissingParameterType = true -reportMissingSuperCall = true -reportMissingTypeArgument = true -reportNoOverloadImplementation = true -reportOperatorIssue = true -reportOptionalCall = true -reportOptionalContextManager = true -reportOptionalIterable = true -reportOptionalMemberAccess = true -reportOptionalOperand = true -reportOptionalSubscript = true -reportOverlappingOverload = true -reportPossiblyUnboundVariable = true -reportPrivateImportUsage = true -reportPrivateUsage = true -reportPropertyTypeMismatch = true -reportRedeclaration = true -reportReturnType = true -reportSelfClsParameterName = true -reportShadowedImports = true -reportTypeCommentUsage = true -reportTypedDictNotRequiredAccess = true -reportUnboundVariable = true -reportUndefinedVariable = true -reportUnhashable = true -reportUninitializedInstanceVariable = true -reportUnknownArgumentType = true -reportUnknownLambdaType = true -reportUnknownMemberType = true -reportUnknownParameterType = true -reportUnknownVariableType = true -reportUnnecessaryCast = true -reportUnnecessaryComparison = true -reportUnnecessaryContains = true -reportUnnecessaryIsInstance = true -reportUnnecessaryTypeIgnoreComment = true -reportUnsupportedDunderAll = true -reportUntypedBaseClass = true -reportUntypedClassDecorator = true -reportUntypedFunctionDecorator = true -reportUntypedNamedTuple = true -reportUnusedCallResult = true -reportUnusedClass = true -reportUnusedCoroutine = true -reportUnusedExcept = true -reportUnusedExpression = true -reportUnusedFunction = true -reportUnusedImport = true -reportUnusedVariable = true -reportWildcardImportFromLibrary = true - -reportDeprecated = true - -# Can be enabled by generating type stubs for modules via pyright CLI -reportMissingTypeStubs = false - -# Bad rules -# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 186b75593a0b4619d944c1f243a26eb8191584ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 21:38:12 +0100 Subject: [PATCH 008/142] Add python version file --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 From f1fbf8e39fa542091bdfd52b003514f1e30a370b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 22:14:03 +0100 Subject: [PATCH 009/142] Improve pyproject and update precommit config after testing that check pass creating a PR at nvdaes/translateNvdaaddonsWithCrowdin repo --- .pre-commit-config.yaml | 97 ++++++++++++++++++-- pyproject.toml | 197 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd7a9d6..75d507a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,92 @@ +# Copied from https://github.com/nvaccess/nvda +# https://pre-commit.ci/ +# Configuration for Continuous Integration service +ci: + # Pyright does not seem to work in pre-commit CI + skip: [pyright] + autoupdate_schedule: monthly + autoupdate_commit_msg: "Pre-commit auto-update" + autofix_commit_msg: "Pre-commit auto-fix" + submodules: true + +default_language_version: + python: python3.13 + repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: check-ast - - id: check-case-conflict - - id: check-yaml +- repo: https://github.com/pre-commit-ci/pre-commit-ci-config + rev: v1.6.1 + hooks: + - id: check-pre-commit-ci-config + +- repo: meta + hooks: + # ensures that exclude directives apply to any file in the repository. + - id: check-useless-excludes + # ensures that the configured hooks apply to at least one file in the repository. + - id: check-hooks-apply + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Prevents commits to certain branches + - id: no-commit-to-branch + args: ["--branch", "main", ] + # Checks that large files have not been added. Default cut-off for "large" files is 500kb. + - id: check-added-large-files + # Checks python syntax + - id: check-ast + # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) + - id: check-case-conflict + # Checks for artifacts from resolving merge conflicts. + - id: check-merge-conflict + # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. + - id: debug-statements + # Removes trailing whitespace. + - id: trailing-whitespace + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Ensures all files end in 1 (and only 1) newline. + - id: end-of-file-fixer + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Removes the UTF-8 BOM from files that have it. + # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding + - id: fix-byte-order-marker + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Validates TOML files. + - id: check-toml + # Validates YAML files. + - id: check-yaml + # Ensures that links to lines in files under version control point to a particular commit. + - id: check-vcs-permalinks + # Avoids using reserved Windows filenames. + - id: check-illegal-windows-names +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.2.0 + hooks: + # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, + # if a trailing comma is added. + # This adds a trailing comma to args/iterable items in case it was missed. + - id: add-trailing-comma + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Matches Ruff version in pyproject. + rev: v0.12.7 + hooks: + - id: ruff + name: lint with ruff + args: [ --fix ] + - id: ruff-format + name: format with ruff + +- repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.406 + hooks: + - id: pyright + name: Check types with pyright + additional_dependencies: [ "pyright[nodejs]==1.1.406" ] + +- repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.18.1 + hooks: + - id: markdownlint-cli2 + name: Lint markdown files + args: ["--fix"] diff --git a/pyproject.toml b/pyproject.toml index 44d0016..ab571c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,26 @@ +[build-system] +requires = ["setuptools~=72.0", "wheel"] +build-backend = "setuptools.build_meta" + [project] name = "addonTemplate" +dynamic = ["version"] version = "0.1.0" -description = "Addon template" -readme = "readme.md" -requires-python = ">=3.13" +description = "Add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme="readme.md" +license = {file = "LICENSE"} dependencies = [ "crowdin-api-client==1.21.0", "lxml>=6.0.1", @@ -15,3 +32,177 @@ dependencies = [ "requests>=2.32.5", "SCons==4.10.1", ] + +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = "../nvda/.venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportDuplicateImport = true +reportIncompleteStub = true +reportInconsistentOverload = true +reportInconsistentConstructor = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingModuleSource = true +reportMissingImports = true +reportNoOverloadImplementation = true +reportOptionalContextManager = true +reportOverlappingOverload = true +reportPrivateImportUsage = true +reportPropertyTypeMismatch = true +reportSelfClsParameterName = true +reportShadowedImports = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUndefinedVariable = true +reportUnusedExpression = true +reportUnboundVariable = true +reportUnhashable = true +reportUnnecessaryCast = true +reportUnnecessaryContains = true +reportUnnecessaryTypeIgnoreComment = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportDeprecated = true +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false +reportUnsupportedDunderAll = false +reportAbstractUsage = false +reportUntypedBaseClass = false +reportOptionalIterable = false +reportCallInDefaultInitializer = false +reportInvalidTypeArguments = false +reportUntypedNamedTuple = false +reportRedeclaration = false +reportOptionalCall = false +reportConstantRedefinition = false +reportWildcardImportFromLibrary = false +reportIncompatibleVariableOverride = false +reportInvalidTypeForm = false +reportGeneralTypeIssues = false +reportOptionalOperand = false +reportUnnecessaryComparison = false +reportFunctionMemberAccess = false +reportUnnecessaryIsInstance = false +reportUnusedFunction = false +reportImportCycles = false +reportUnusedImport = false +reportUnusedVariable = false +reportOperatorIssue = false +reportAssignmentType = false +reportReturnType = false +reportPossiblyUnboundVariable = false +reportMissingSuperCall = false +reportUninitializedInstanceVariable = false +reportUnknownLambdaType = false +reportMissingTypeArgument = false +reportImplicitStringConcatenation = false +reportIncompatibleMethodOverride = false +reportPrivateUsage = false +reportUnusedCallResult = false +reportOptionalSubscript = false +reportCallIssue = false +reportOptionalMemberAccess = false +reportImplicitOverride = false +reportIndexIssue = false +reportAttributeAccessIssue = false +reportArgumentType = false +reportUnknownParameterType = false +reportMissingParameterType = false +reportUnknownVariableType = false +reportUnknownArgumentType = false +reportUnknownMemberType = false +lint = [ + "ruff==0.8.1", + "pre-commit==4.0.1", + "pyright==1.1.396", +] + From b867a9a0f25aa5d5b56bca11c12083fe5298795e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 07:05:11 +0100 Subject: [PATCH 010/142] Restore rules --- pyproject.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ab571c4..bf69408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,18 +118,37 @@ strictDictionaryInference = true strictSetInference = true # Compliant rules +reportAbstractUsage = true +reportArgumentType = true reportAssertAlwaysTrue = true reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true reportIncompleteStub = true +reportIndexIssue = true reportInconsistentOverload = true reportInconsistentConstructor = true reportInvalidStringEscapeSequence = true reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true reportMissingModuleSource = true reportMissingImports = true +reportMissingParameterType = true +reportMissingSuperCall = true reportNoOverloadImplementation = true reportOptionalContextManager = true reportOverlappingOverload = true From 47ed91cde7c16799122428ac0d50b294aa89ffa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 20:44:09 +0100 Subject: [PATCH 011/142] Restore pyproject --- pyproject.toml | 140 +++++++++++++------------------------------------ 1 file changed, 37 insertions(+), 103 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf69408..97189ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,41 +1,3 @@ -[build-system] -requires = ["setuptools~=72.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "addonTemplate" -dynamic = ["version"] -version = "0.1.0" -description = "Add-on template" -maintainers = [ - {name = "NV Access", email = "info@nvaccess.org"}, -] -requires-python = ">=3.13,<3.14" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python :: 3", - "Topic :: Accessibility", -] -readme="readme.md" -license = {file = "LICENSE"} -dependencies = [ - "crowdin-api-client==1.21.0", - "lxml>=6.0.1", - "markdown>=3.9", - "markdown-link-attr-modifier==0.2.1", - "mdx-gh-links==0.4", - "mdx-truly-sane-lists==1.3", - "nh3==0.2.19", - "requests>=2.32.5", - "SCons==4.10.1", -] - -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" - [tool.ruff] line-length = 110 @@ -58,13 +20,10 @@ include = [ exclude = [ ".git", "__pycache__", - ".venv", - "buildVars.py", ] [tool.ruff.format] indent-style = "tab" -line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -74,16 +33,13 @@ ignore = [ # indentation contains tabs "W191", ] -logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconscripts contains many inbuilt functions not recognised by the lint, +# sconstruct contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] -venvPath = "../nvda/.venv" -venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -95,7 +51,6 @@ exclude = [ "sconstruct", ".git", "__pycache__", - ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -104,7 +59,6 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", - "../nvda/source", ] # General config @@ -136,92 +90,72 @@ reportImportCycles = true reportIncompatibleMethodOverride = true reportIncompatibleVariableOverride = true reportIncompleteStub = true -reportIndexIssue = true -reportInconsistentOverload = true reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true reportInvalidStringEscapeSequence = true reportInvalidStubStatement = true reportInvalidTypeArguments = true reportInvalidTypeForm = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true -reportMissingModuleSource = true reportMissingImports = true +reportMissingModuleSource = true reportMissingParameterType = true reportMissingSuperCall = true +reportMissingTypeArgument = true reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true reportOverlappingOverload = true +reportPossiblyUnboundVariable = true reportPrivateImportUsage = true +reportPrivateUsage = true reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true reportSelfClsParameterName = true reportShadowedImports = true reportTypeCommentUsage = true reportTypedDictNotRequiredAccess = true -reportUndefinedVariable = true -reportUnusedExpression = true reportUnboundVariable = true +reportUndefinedVariable = true reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true reportUnnecessaryCast = true +reportUnnecessaryComparison = true reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true reportUntypedClassDecorator = true reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true reportUnusedClass = true reportUnusedCoroutine = true reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + reportDeprecated = true + # Can be enabled by generating type stubs for modules via pyright CLI reportMissingTypeStubs = false -reportUnsupportedDunderAll = false -reportAbstractUsage = false -reportUntypedBaseClass = false -reportOptionalIterable = false -reportCallInDefaultInitializer = false -reportInvalidTypeArguments = false -reportUntypedNamedTuple = false -reportRedeclaration = false -reportOptionalCall = false -reportConstantRedefinition = false -reportWildcardImportFromLibrary = false -reportIncompatibleVariableOverride = false -reportInvalidTypeForm = false -reportGeneralTypeIssues = false -reportOptionalOperand = false -reportUnnecessaryComparison = false -reportFunctionMemberAccess = false -reportUnnecessaryIsInstance = false -reportUnusedFunction = false -reportImportCycles = false -reportUnusedImport = false -reportUnusedVariable = false -reportOperatorIssue = false -reportAssignmentType = false -reportReturnType = false -reportPossiblyUnboundVariable = false -reportMissingSuperCall = false -reportUninitializedInstanceVariable = false -reportUnknownLambdaType = false -reportMissingTypeArgument = false -reportImplicitStringConcatenation = false -reportIncompatibleMethodOverride = false -reportPrivateUsage = false -reportUnusedCallResult = false -reportOptionalSubscript = false -reportCallIssue = false -reportOptionalMemberAccess = false -reportImplicitOverride = false -reportIndexIssue = false -reportAttributeAccessIssue = false -reportArgumentType = false -reportUnknownParameterType = false -reportMissingParameterType = false -reportUnknownVariableType = false -reportUnknownArgumentType = false -reportUnknownMemberType = false -lint = [ - "ruff==0.8.1", - "pre-commit==4.0.1", - "pyright==1.1.396", -] +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 402002eb5a86e14e241c6df5aaade0fa7acc3ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 21:43:29 +0100 Subject: [PATCH 012/142] Improve uv project --- .gitignore | 18 +++- 2.32.5 | 0 3.9 | 0 6.0.1 | 0 pyproject.toml | 52 +++++++++- uv.lock | 267 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 2.32.5 create mode 100644 3.9 create mode 100644 6.0.1 create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 0be8af1..1750f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,23 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Files generated for add-ons addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -manifest.ini +addon/*.ini +addon/locale/*/*.ini *.mo *.pot -*.py[co] +*.pyc *.nvda-addon .sconsign.dblite -/[0-9]*.[0-9]*.[0-9]*.json diff --git a/2.32.5 b/2.32.5 new file mode 100644 index 0000000..e69de29 diff --git a/3.9 b/3.9 new file mode 100644 index 0000000..e69de29 diff --git a/6.0.1 b/6.0.1 new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 97189ac..4673a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ +[build-system] +requires = ["setuptools~=72.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +dependencies = [ + "crowdin-api-client==1.21.0", + "lxml>=6.0.2", + "markdown>=3.10", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + "mdx-truly-sane-lists==1.3", + "nh3==0.2.19", + "requests>=2.32.5", + "scons==4.10.1", +] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + [tool.ruff] line-length = 110 @@ -20,10 +56,13 @@ include = [ exclude = [ ".git", "__pycache__", + ".venv", + "buildVars.py", ] [tool.ruff.format] indent-style = "tab" +line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -33,13 +72,16 @@ ignore = [ # indentation contains tabs "W191", ] +logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, +# sconscripts contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] +venvPath = "../nvda/.venv" +venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -51,6 +93,7 @@ exclude = [ "sconstruct", ".git", "__pycache__", + ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -59,6 +102,7 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", + "../nvda/source", ] # General config @@ -159,3 +203,9 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. +lint = [ + "ruff==0.8.1", + "pre-commit==4.0.1", + "pyright==1.1.396", +] + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..58c3f26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,267 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "requests" }, + { name = "scons" }, +] + +[package.metadata] +requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.21.0" }, + { name = "lxml", specifier = ">=6.0.2" }, + { name = "markdown", specifier = ">=3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.2.19" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "scons", specifier = "==4.10.1" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/70/4069412e2e8004a6ad15bf2a3d9085bea50ee932a66ad935285831cf82b4/crowdin_api_client-1.21.0.tar.gz", hash = "sha256:0f957e5de6487a74ac892d524a5e300c1bc971320b67f85ce65741904420d8ec", size = 64729, upload-time = "2025-01-31T15:56:42.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/0f/9efc4f6db0b3b97f99015529b5832058ce4f7970d547b23fc04a38d69ddd/crowdin_api_client-1.21.0-py3-none-any.whl", hash = "sha256:85e19557755ebf6a15beda605d25b77de365244d8c636462b0dd8030a6cdfe20", size = 101566, upload-time = "2025-01-31T15:56:40.651Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.2.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/32/3b8d8471d006333bac3175ad37402414d985ed3f8650a01a33e0e86b9824/nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804", size = 17327, upload-time = "2024-11-30T04:05:56.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/1d/cbd75a2313d96cd3903111667d3d07548fb45c8ecf5c315f37a8f6c202fa/nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6", size = 1205181, upload-time = "2024-11-29T05:50:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/8e9ec472ce575fa6b98935920c91df637bf9342862bd943745441aec99eb/nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0", size = 739174, upload-time = "2024-11-29T05:50:10.157Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b5/d1f81c5ec5695464b69d8aa4529ecb5fd872cbfb29f879b4063bb9397da8/nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37", size = 758660, upload-time = "2024-11-29T05:50:13.97Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/295a3a069f3b9dc35527eedd7b212f31311ef1f66a0e5f5f0acad6db9456/nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a", size = 924377, upload-time = "2024-11-29T05:50:15.716Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/0f189d5054f22cdfdb16d16a2a41282f411a4c03f8418be47e0480bd5bfd/nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd", size = 992124, upload-time = "2024-11-29T05:50:17.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/87/2907edd61a2172527c5322036aa95ce6c18432ff280fc5cf78fe0f934c65/nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9", size = 913939, upload-time = "2024-11-29T05:50:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/e0d3ea0175f28032d7d2bab765250f4e94ef131a7b3293e3df4cb254a5b2/nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55", size = 909051, upload-time = "2024-11-29T05:50:31.116Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/f52cb1d54ba965b7d8bb1c884ca982be31d7f75ad9e7e5817f4af20002b3/nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc", size = 540566, upload-time = "2024-11-29T05:50:38.437Z" }, + { url = "https://files.pythonhosted.org/packages/70/85/91a66edfab0adbf22468973d8abd4b93c951bbcbbe2121675ee468b912a2/nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3", size = 542368, upload-time = "2024-11-29T05:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/32/9c/f8808cf6683d4852ba8862e25b98aa9116067ddec517938a1b6e8faadb43/nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41", size = 1205140, upload-time = "2024-11-29T05:50:54.582Z" }, + { url = "https://files.pythonhosted.org/packages/04/0e/268401d9244a84935342d9f3ba5d22bd7d2fc10cfc7a8f59bde8f6721466/nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a", size = 763571, upload-time = "2024-11-29T05:51:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/8be706feb6637d6e5db0eed09fd3f4e1008aee3d5d7161c9973d7aae1d13/nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69", size = 750319, upload-time = "2024-11-29T05:51:05.512Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/1f5f9ba0194f6a882e4bda89ae831678e4b68aa3de91e11e2629a1e6a613/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9", size = 857636, upload-time = "2024-11-29T05:51:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/5661a66f2950879a81fde5fbb6beb650c5647776aaec1a676e6b3ff4b6e5/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c", size = 821081, upload-time = "2024-11-29T05:51:08.202Z" }, + { url = "https://files.pythonhosted.org/packages/22/f8/454828f6f21516bf0c8c578e8bc2ab4f045e6b6fe5179602fe4dc2479da6/nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1", size = 894452, upload-time = "2024-11-29T05:51:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/36f5b78cbc631cac1c993bdc4608a0fe3148214bdb6d2c1266e228a2686a/nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc", size = 748281, upload-time = "2024-11-29T05:51:29.021Z" }, + { url = "https://files.pythonhosted.org/packages/98/da/d04f5f0e7ee8edab8ceecdbba9f1c614dc8cf07374141ff6ea3b615b3479/nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707", size = 767109, upload-time = "2024-11-29T05:51:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5b/1232fb35c7d1182adb7d513fede644a81b5361259749781e6075c40a9125/nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22", size = 924295, upload-time = "2024-11-29T05:51:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fd/ae622d08518fd31360fd87a515700bc09913f2e57e7f010063f2193ea610/nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d", size = 992038, upload-time = "2024-11-29T05:51:34.414Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/226577c5e3fe379cb95265aa77736e191d859032c974169e6879c51c156f/nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9", size = 913866, upload-time = "2024-11-29T05:51:35.727Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ca/bbd2b2dab31ceae38cfa673861cab81df5ed5be1fe47b6c4f5aa41729aa2/nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0", size = 908976, upload-time = "2024-11-29T05:51:43.698Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8f/6452eb1184ad87cdd2cac7ee3ebd67a2aadb554d25572c1778efdf807e1e/nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48", size = 540528, upload-time = "2024-11-29T05:51:45.312Z" }, + { url = "https://files.pythonhosted.org/packages/58/d6/285df10307f16fcce9afbd133b04b4bc7d7f9b84b02f0f724bab30dacdd9/nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121", size = 542316, upload-time = "2024-11-29T05:52:01.253Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] From d82071137cd1e13db770fc2a3de98b9dc8363f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 21:44:57 +0100 Subject: [PATCH 013/142] Remove files --- 2.32.5 | 0 3.9 | 0 6.0.1 | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 2.32.5 delete mode 100644 3.9 delete mode 100644 6.0.1 diff --git a/2.32.5 b/2.32.5 deleted file mode 100644 index e69de29..0000000 diff --git a/3.9 b/3.9 deleted file mode 100644 index e69de29..0000000 diff --git a/6.0.1 b/6.0.1 deleted file mode 100644 index e69de29..0000000 From 9f6b3dc35c6d3ff8ad4d6be6200248885d2aec20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 29 Nov 2025 17:41:31 +0100 Subject: [PATCH 014/142] Calculate hash of i18nSources --- .github/workflows/exportAddonToCrowdin.yml | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 0b8dd9e..857f1d6 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -35,41 +35,52 @@ jobs: scons pot - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Get add-on id - id: getAddonId + - name: Get add-on info + id: getAddonInfo shell: python run: | - import os, sys + import os, sys, hashlib sys.path.insert(0, os.getcwd()) import buildVars addonId = buildVars.addon_info["addon_name"] + i18nSources = buildVars.i18nSources + hasher = hashlib.sha256() + for file in i18nSources: + if os.path.isfile(file): + with open(file, "rb") as f: + while chunk := f.read(8192): + hasher.update(chunk) + hashValue = hasher.hexdigest() name = 'addonId' value = addonId + name2 = 'hashValue' + value2 = hashValue + print(hashValue) with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}") + f.write(f"{name}={value}\n{name2}={value2}") - name: Generate xliff if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp - mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp + mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff fi - name: Upload to Crowdin if: ${{ !inputs.update }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.pot + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - name: Update sources if: ${{ inputs.update }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} From 4c938eccedc7d8836482c27bdaa0aa68f72983e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 30 Nov 2025 14:35:49 +0100 Subject: [PATCH 015/142] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 76 ++++++++++++---------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 857f1d6..1547b3c 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -8,9 +8,13 @@ on: description: 'true to update preexisting sources, false to add them from scratch' type: boolean required: false + default: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} jobs: build: runs-on: ubuntu-latest @@ -39,63 +43,63 @@ jobs: id: getAddonInfo shell: python run: | - import os, sys, hashlib + import os, sys, json sys.path.insert(0, os.getcwd()) - import buildVars + import buildVars, sha256 addonId = buildVars.addon_info["addon_name"] - i18nSources = buildVars.i18nSources - hasher = hashlib.sha256() - for file in i18nSources: - if os.path.isfile(file): - with open(file, "rb") as f: - while chunk := f.read(8192): - hasher.update(chunk) - hashValue = hasher.hexdigest() + readmeFile = os.path.join(os.getcwd(), "readme.md") + i18nSources = sorted(buildVars.i18nSources) + if os.path.isfile(readmeFile): + readmeSha = sha256.sha256_checksum([readmeFile]) + i18nSourcesSha = sha256.sha256_checksum(i18nSources) + hashFile = os.path.join(os.getcwd(), "hash.json") + data = dict() + if os.path.isfile(hashFile): + with open(hashFile, "rt") as f: + data = json.load(f) + shouldUpdateXliff = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(f"{addonId}.xliff") + shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + data = dict() + if readmeSha: + data["readmeSha"] = readmeSha + if i18nSourcesSha: + data["i18nSourcesSha"] = i18nSourcesSha + with open(hashFile, "wt", encoding="utf-8") as f: + json.dump(data, f, indent="\t", ensure_ascii=False) name = 'addonId' value = addonId - name2 = 'hashValue' - value2 = hashValue - print(hashValue) + name0 = 'shouldUpdateXliff' + value0 = str(shouldUpdateXliff).lower() + name1 = 'shouldUpdatePot' + value1 = str(shouldUpdatePot).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name2}={value2}") - - name: Generate xliff + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + - name: Generate xliff and pot if: ${{ !inputs.update }} run: | uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update xliff - if: ${{ inputs.update }} + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateXliff == 'true' }} run: | uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff - fi - - name: Upload to Crowdin - if: ${{ !inputs.update }} - run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} - crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - - name: Update sources - if: ${{ inputs.update }} + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff + - name: Update pot + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff - env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} - crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - - name: Commit and push json file + - name: Commit and push json and xliff files id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add _l10n/l10n.json + git add hash.json _l10n/l10n.json ${{ steps.getAddonInfo.outputs.addonId}}.xliff if git diff --staged --quiet; then echo "Nothing added to commit." - echo "has_changes=false" >> $GITHUB_OUTPUT else - echo "has_changes=true" >> $GITHUB_OUTPUT - git commit -m "Update Crowdin file ids" + git commit -m "Update Crowdin file ids and hashes" git push fi From a3032100afc7facefc898e15f12a1041c481faf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 30 Nov 2025 18:16:35 +0100 Subject: [PATCH 016/142] Update _l10n --- _l10n/files.json | 1 - _l10n/l10n.json | 2 +- _l10n/markdownTranslate.py | 6 +++++- 3 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 _l10n/files.json diff --git a/_l10n/files.json b/_l10n/files.json deleted file mode 100644 index 9e26dfe..0000000 --- a/_l10n/files.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/_l10n/l10n.json b/_l10n/l10n.json index abf3c01..9e26dfe 100644 --- a/_l10n/l10n.json +++ b/_l10n/l10n.json @@ -1 +1 @@ -{"translateNvdaAddonsWithCrowdin.xliff": 442, "translateNvdaAddonsWithCrowdin.pot": 444} \ No newline at end of file +{} \ No newline at end of file diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index ee70eb7..fa9a186 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -6,6 +6,8 @@ from typing import Generator import tempfile import os +import sys +sys.path.insert(0, os.getcwd()) import contextlib import lxml.etree import argparse @@ -17,7 +19,9 @@ from dataclasses import dataclass import subprocess -RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" +import buildVars + +RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{buildVars.userAccount}/{buildVars.addon_info["addon_name"]}" re_kcTitle = re.compile(r"^()$") re_kcSettingsSection = re.compile(r"^()$") # Comments that span a single line in their entirety From a0d02da4a1e2acd155286cd516ec5aa3d4bb5eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 1 Dec 2025 21:31:13 +0100 Subject: [PATCH 017/142] Upload md file --- .github/workflows/exportAddonToCrowdin.yml | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1547b3c..4ef3da6 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -57,7 +57,7 @@ jobs: if os.path.isfile(hashFile): with open(hashFile, "rt") as f: data = json.load(f) - shouldUpdateXliff = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(f"{addonId}.xliff") + shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) data = dict() if readmeSha: @@ -68,24 +68,25 @@ jobs: json.dump(data, f, indent="\t", ensure_ascii=False) name = 'addonId' value = addonId - name0 = 'shouldUpdateXliff' - value0 = str(shouldUpdateXliff).lower() + name0 = 'shouldUpdateMd' + value0 = str(shouldUpdateMd).lower() name1 = 'shouldUpdatePot' value1 = str(shouldUpdatePot).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") - - name: Generate xliff and pot + - name: Generate source files if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + if -f readme.md; then + mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md + fi uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: update xliff - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateXliff == 'true' }} + - name: update md + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp - mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff + mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Update pot if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | @@ -96,7 +97,7 @@ jobs: git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add hash.json _l10n/l10n.json ${{ steps.getAddonInfo.outputs.addonId}}.xliff + git add hash.json _l10n/l10n.json if git diff --staged --quiet; then echo "Nothing added to commit." else From a8d42520dcd71d303f6aaa1cd073e29051f1b3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 3 Dec 2025 18:59:28 +0100 Subject: [PATCH 018/142] Updates --- _l10n/l10n.json | 1 - _l10n/l10nUtil.py | 64 ++++++++++++++++++----------------------------- 2 files changed, 24 insertions(+), 41 deletions(-) delete mode 100644 _l10n/l10n.json diff --git a/_l10n/l10n.json b/_l10n/l10n.json deleted file mode 100644 index 9e26dfe..0000000 --- a/_l10n/l10n.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 6cd4352..68725aa 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -24,7 +24,8 @@ import buildVars -CROWDIN_PROJECT_ID = 780748 + +CROWDIN_PROJECT_ID = os.getenv("crowdinProjectID", "").strip() POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") @@ -266,12 +267,6 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(localFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(localFilePath) res = getCrowdinClient().storages.add_storage( open(localFilePath, "rb"), ) @@ -279,40 +274,30 @@ def uploadSourceFile(localFilePath: str): raise ValueError("Crowdin storage upload failed") storageId = res["data"]["id"] print(f"Stored with ID {storageId}") - filename = os.path.basename(localFilePath) - fileId = files.get(filename) - print(f"File ID: {fileId}") - match fileId: - case None: - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = ( - f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - ) - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Importing source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - print("Done") - case _: - res = getCrowdinClient().source_files.update_file( - fileId=fileId, - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - ) + addonId = buildVars.addon_info["addon_name"] + filename = addonId + if os.path.splitext(filename)[1] == ".pot": + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + else: + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern, + } + print(f"Exporting source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) + getFiles() + print("Done") -def getFiles() -> dict[str, str]: +def getFiles() -> None: """Gets files from Crowdin, and write them to a json file.""" addonId = buildVars.addon_info["addon_name"] @@ -329,7 +314,6 @@ def getFiles() -> dict[str, str]: dictionary[name] = id with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: json.dump(dictionary, jsonFile, ensure_ascii=False) - return dictionary def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): From 1a1e6fdb476a42ecd5a27b2885ec06c0e3d7b87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 14 Dec 2025 13:19:12 +0100 Subject: [PATCH 019/142] Update l10nUtil --- _l10n/l10nUtil.py | 64 +++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 68725aa..00dab1a 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -267,6 +267,14 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ + if not os.path.isfile(L10N_FILE): + getFiles() + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(localFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(localFilePath) res = getCrowdinClient().storages.add_storage( open(localFilePath, "rb"), ) @@ -274,30 +282,41 @@ def uploadSourceFile(localFilePath: str): raise ValueError("Crowdin storage upload failed") storageId = res["data"]["id"] print(f"Stored with ID {storageId}") - addonId = buildVars.addon_info["addon_name"] - filename = addonId - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Exporting source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - getFiles() - print("Done") + filename = os.path.basename(localFilePath) + fileId = files.get(filename) + print(f"File ID: {fileId}") + match fileId: + case None: + if os.path.splitext(filename)[1] == ".pot": + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = ( + f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + ) + else: + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern, + } + print(f"Exporting source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) + print("Done") + case _: + res = getCrowdinClient().source_files.update_file( + fileId=fileId, + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + ) + -def getFiles() -> None: +def getFiles() -> dict[str, int]: """Gets files from Crowdin, and write them to a json file.""" addonId = buildVars.addon_info["addon_name"] @@ -314,6 +333,7 @@ def getFiles() -> None: dictionary[name] = id with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: json.dump(dictionary, jsonFile, ensure_ascii=False) + return dictionary def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): From d2395b0ddecec7e026d1058f3c4cd4e9122ea23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 14 Dec 2025 13:20:38 +0100 Subject: [PATCH 020/142] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4ef3da6..b7da396 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -77,10 +77,8 @@ jobs: - name: Generate source files if: ${{ !inputs.update }} run: | - if -f readme.md; then mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - fi uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update md if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} @@ -91,13 +89,13 @@ jobs: if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json and xliff files + - name: Commit and push json id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add hash.json _l10n/l10n.json + git add _l10n/l10n.json if git diff --staged --quiet; then echo "Nothing added to commit." else From e4dafe1492e008f1a2e99f3e23c881135692ab50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 06:00:36 +0100 Subject: [PATCH 021/142] Update readme --- readme.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/readme.md b/readme.md index 05e5f12..82877a0 100644 --- a/readme.md +++ b/readme.md @@ -146,6 +146,20 @@ Note: you must fill out this dictionary if at least one custom symbol dictionary * channel: update channel (do not use this switch unless you know what you are doing). * dev: suitable for development builds, names the add-on according to current date (yyyymmdd) and sets update channel to "dev". + +### Translation workflow + +You can add the documentation and interface messages of your add-on to be translated in Crowdin. + +You need a Crowdin account and an API token with permissions to push to a Crowdin project. +For example, you may want to use this [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons). + +Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. +When you have updated messages or documentation, run the workflow setting update to true (which is the default option). + + + + ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From f76904eeecbb761e6dc4af2657cdd656f46abaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 17:43:33 +0100 Subject: [PATCH 022/142] Update readme.md Co-authored-by: Sean Budd --- readme.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/readme.md b/readme.md index 82877a0..6a61fcd 100644 --- a/readme.md +++ b/readme.md @@ -157,9 +157,6 @@ For example, you may want to use this [Crowdin project to translate NVDA add-ons Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. When you have updated messages or documentation, run the workflow setting update to true (which is the default option). - - - ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From aea5ebac891f84320796aa5de9af5f7a51d17852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 17:44:29 +0100 Subject: [PATCH 023/142] Update _l10n/crowdinSync.py Co-authored-by: Sean Budd --- _l10n/crowdinSync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py index 1a56070..0d5ceec 100644 --- a/_l10n/crowdinSync.py +++ b/_l10n/crowdinSync.py @@ -1,6 +1,6 @@ # A part of NonVisual Desktop Access (NVDA) # based on file from https://github.com/jcsteh/osara -# Copyright (C) 2023-2024 NV Access Limited, James Teh +# Copyright (C) 2023-2025 NV Access Limited, James Teh # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html From f7ccaf68ff57404cbd071b8cadef024af0f65618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 19:42:07 +0100 Subject: [PATCH 024/142] Add setOutput.py to separate Python code from yaml file --- .github/workflows/exportAddonToCrowdin.yml | 34 +----------------- .github/workflows/setOutputs.py | 42 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/setOutputs.py diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index b7da396..31f4871 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -41,39 +41,7 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Get add-on info id: getAddonInfo - shell: python - run: | - import os, sys, json - sys.path.insert(0, os.getcwd()) - import buildVars, sha256 - addonId = buildVars.addon_info["addon_name"] - readmeFile = os.path.join(os.getcwd(), "readme.md") - i18nSources = sorted(buildVars.i18nSources) - if os.path.isfile(readmeFile): - readmeSha = sha256.sha256_checksum([readmeFile]) - i18nSourcesSha = sha256.sha256_checksum(i18nSources) - hashFile = os.path.join(os.getcwd(), "hash.json") - data = dict() - if os.path.isfile(hashFile): - with open(hashFile, "rt") as f: - data = json.load(f) - shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) - shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - data = dict() - if readmeSha: - data["readmeSha"] = readmeSha - if i18nSourcesSha: - data["i18nSourcesSha"] = i18nSourcesSha - with open(hashFile, "wt", encoding="utf-8") as f: - json.dump(data, f, indent="\t", ensure_ascii=False) - name = 'addonId' - value = addonId - name0 = 'shouldUpdateMd' - value0 = str(shouldUpdateMd).lower() - name1 = 'shouldUpdatePot' - value1 = str(shouldUpdatePot).lower() - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + run: uv run ./.github/workflows/setOutputs.py - name: Generate source files if: ${{ !inputs.update }} run: | diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py new file mode 100644 index 0000000..5e0e5d5 --- /dev/null +++ b/.github/workflows/setOutputs.py @@ -0,0 +1,42 @@ +# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os, sys, json +sys.path.insert(0, os.getcwd()) +import buildVars, sha256 + + +def main(): + addonId = buildVars.addon_info["addon_name"] + readmeFile = os.path.join(os.getcwd(), "readme.md") + i18nSources = sorted(buildVars.i18nSources) + if os.path.isfile(readmeFile): + readmeSha = sha256.sha256_checksum([readmeFile]) + i18nSourcesSha = sha256.sha256_checksum(i18nSources) + hashFile = os.path.join(os.getcwd(), "hash.json") + data = dict() + if os.path.isfile(hashFile): + with open(hashFile, "rt") as f: + data = json.load(f) + shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) + shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + data = dict() + if readmeSha: + data["readmeSha"] = readmeSha + if i18nSourcesSha: + data["i18nSourcesSha"] = i18nSourcesSha + with open(hashFile, "wt", encoding="utf-8") as f: + json.dump(data, f, indent="\t", ensure_ascii=False) + name = 'addonId' + value = addonId + name0 = 'shouldUpdateMd' + value0 = str(shouldUpdateMd).lower() + name1 = 'shouldUpdatePot' + value1 = str(shouldUpdatePot).lower() + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + + +if __name__ == "__main__": + main() \ No newline at end of file From 0276e2270735ce5915a7bae3318afb9349076c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:35:17 +0100 Subject: [PATCH 025/142] Remove bad comment --- _l10n/crowdinSync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py index 0d5ceec..e879bba 100644 --- a/_l10n/crowdinSync.py +++ b/_l10n/crowdinSync.py @@ -79,7 +79,7 @@ def main(): "uploadSourceFile", help="Upload a source file to Crowdin.", ) - # uploadCommand.add_argument("crowdinFileID", type=int, help="The Crowdin file ID.") + uploadCommand.add_argument("localFilePath", help="The path to the local file.") args = parser.parse_args() if args.command == "uploadSourceFile": From 253eb461572334ea9af37a0eda8e14fd9e499fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:42:13 +0100 Subject: [PATCH 026/142] Reset pyproject to master --- pyproject.toml | 52 +------------------------------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4673a1c..97189ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,39 +1,3 @@ -[build-system] -requires = ["setuptools~=72.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "addonTemplate" -dynamic = ["version"] -description = "NVDA add-on template" -maintainers = [ - {name = "NV Access", email = "info@nvaccess.org"}, -] -requires-python = ">=3.13,<3.14" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python :: 3", - "Topic :: Accessibility", -] -readme = "readme.md" -license = {file = "COPYING.TXT"} -dependencies = [ - "crowdin-api-client==1.21.0", - "lxml>=6.0.2", - "markdown>=3.10", - "markdown-link-attr-modifier==0.2.1", - "mdx-gh-links==0.4", - "mdx-truly-sane-lists==1.3", - "nh3==0.2.19", - "requests>=2.32.5", - "scons==4.10.1", -] -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" - [tool.ruff] line-length = 110 @@ -56,13 +20,10 @@ include = [ exclude = [ ".git", "__pycache__", - ".venv", - "buildVars.py", ] [tool.ruff.format] indent-style = "tab" -line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -72,16 +33,13 @@ ignore = [ # indentation contains tabs "W191", ] -logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconscripts contains many inbuilt functions not recognised by the lint, +# sconstruct contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] -venvPath = "../nvda/.venv" -venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -93,7 +51,6 @@ exclude = [ "sconstruct", ".git", "__pycache__", - ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -102,7 +59,6 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", - "../nvda/source", ] # General config @@ -203,9 +159,3 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. -lint = [ - "ruff==0.8.1", - "pre-commit==4.0.1", - "pyright==1.1.396", -] - From c51e7ad894e92233952dc875def5989c7ad308db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:43:29 +0100 Subject: [PATCH 027/142] reset .pre-commit configuration to master --- .pre-commit-config.yaml | 97 +++-------------------------------------- 1 file changed, 6 insertions(+), 91 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75d507a..dd7a9d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,92 +1,7 @@ -# Copied from https://github.com/nvaccess/nvda -# https://pre-commit.ci/ -# Configuration for Continuous Integration service -ci: - # Pyright does not seem to work in pre-commit CI - skip: [pyright] - autoupdate_schedule: monthly - autoupdate_commit_msg: "Pre-commit auto-update" - autofix_commit_msg: "Pre-commit auto-fix" - submodules: true - -default_language_version: - python: python3.13 - repos: -- repo: https://github.com/pre-commit-ci/pre-commit-ci-config - rev: v1.6.1 - hooks: - - id: check-pre-commit-ci-config - -- repo: meta - hooks: - # ensures that exclude directives apply to any file in the repository. - - id: check-useless-excludes - # ensures that the configured hooks apply to at least one file in the repository. - - id: check-hooks-apply - -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - # Prevents commits to certain branches - - id: no-commit-to-branch - args: ["--branch", "main", ] - # Checks that large files have not been added. Default cut-off for "large" files is 500kb. - - id: check-added-large-files - # Checks python syntax - - id: check-ast - # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) - - id: check-case-conflict - # Checks for artifacts from resolving merge conflicts. - - id: check-merge-conflict - # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. - - id: debug-statements - # Removes trailing whitespace. - - id: trailing-whitespace - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Ensures all files end in 1 (and only 1) newline. - - id: end-of-file-fixer - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Removes the UTF-8 BOM from files that have it. - # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding - - id: fix-byte-order-marker - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Validates TOML files. - - id: check-toml - # Validates YAML files. - - id: check-yaml - # Ensures that links to lines in files under version control point to a particular commit. - - id: check-vcs-permalinks - # Avoids using reserved Windows filenames. - - id: check-illegal-windows-names -- repo: https://github.com/asottile/add-trailing-comma - rev: v3.2.0 - hooks: - # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, - # if a trailing comma is added. - # This adds a trailing comma to args/iterable items in case it was missed. - - id: add-trailing-comma - -- repo: https://github.com/astral-sh/ruff-pre-commit - # Matches Ruff version in pyproject. - rev: v0.12.7 - hooks: - - id: ruff - name: lint with ruff - args: [ --fix ] - - id: ruff-format - name: format with ruff - -- repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.406 - hooks: - - id: pyright - name: Check types with pyright - additional_dependencies: [ "pyright[nodejs]==1.1.406" ] - -- repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.18.1 - hooks: - - id: markdownlint-cli2 - name: Lint markdown files - args: ["--fix"] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: check-case-conflict + - id: check-yaml From cd4816c0cbf43d9585dda95812870b8db5096fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 04:53:36 +0100 Subject: [PATCH 028/142] Remove userAccount variable, since we use markdown, not xliff --- buildVars.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/buildVars.py b/buildVars.py index 770946a..a3fe862 100644 --- a/buildVars.py +++ b/buildVars.py @@ -10,8 +10,6 @@ # which returns whatever is given to it as an argument. from site_scons.site_tools.NVDATool.utils import _ -# The GitHub user account to generate xliff file for translations -userAccount: str | None = None # Add-on information variables addon_info = AddonInfo( # add-on Name/identifier, internal for NVDA From 314220bdc5bbda0e131efd490b7878aa02c9b514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 05:31:26 +0100 Subject: [PATCH 029/142] Update or add files from scratch depending on existence of hashFile --- .github/workflows/exportAddonToCrowdin.yml | 19 ++++++++----------- .github/workflows/setOutputs.py | 3 +++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 31f4871..1b8a26c 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -3,12 +3,6 @@ name: Export add-on to Crowdin on: workflow_dispatch: - inputs: - update: - description: 'true to update preexisting sources, false to add them from scratch' - type: boolean - required: false - default: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -42,19 +36,22 @@ jobs: - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - - name: Generate source files - if: ${{ !inputs.update }} + - name: Upload md from scratch + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'false' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update md - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md + - name: Upload pot from scratch + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'false' }} + run: | + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index 5e0e5d5..da853bc 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -21,6 +21,9 @@ def main(): data = json.load(f) shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + else: + shouldUpdateMd = False + shouldUpdatePot = False data = dict() if readmeSha: data["readmeSha"] = readmeSha From f3e8b8d87518e5e579d771d1f0b948eafcf1a5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 05:42:56 +0100 Subject: [PATCH 030/142] Use addMd and addPotFromScratch outputs --- .github/workflows/exportAddonToCrowdin.yml | 4 ++-- .github/workflows/setOutputs.py | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1b8a26c..4d01c88 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -37,7 +37,7 @@ jobs: id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'false' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md @@ -47,7 +47,7 @@ jobs: mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'false' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} run: | uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index da853bc..9af5839 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -21,9 +21,8 @@ def main(): data = json.load(f) shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - else: - shouldUpdateMd = False - shouldUpdatePot = False + shouldAddMdFromScratch = not os.path.isfile(hashFile) and not shouldUpdateMd + shouldAddPotFromScratch = not os.path.isfile(hashFile) and not shouldUpdatePot data = dict() if readmeSha: data["readmeSha"] = readmeSha @@ -37,8 +36,12 @@ def main(): value0 = str(shouldUpdateMd).lower() name1 = 'shouldUpdatePot' value1 = str(shouldUpdatePot).lower() + name2 = shouldAddMdFromScratch + value2 = str(shouldAddMdFromScratch).lower() + name3 = shouldAddPotFromScratch + value3 = str(shouldAddPotFromScratch).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") if __name__ == "__main__": From de4fa152b9172ffa40082f7a8d33414f48152867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:17:09 +0100 Subject: [PATCH 031/142] Update dependencies --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 97189ac..6178665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,10 @@ +dependencies = [ + "SCons==4.10.1", + "Markdown==3.10", + "ruff==0.14.5", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", +] [tool.ruff] line-length = 110 From 46a105aa032263958169b148688119a9415c0e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:19:03 +0100 Subject: [PATCH 032/142] Update setOutput --- .github/workflows/setOutputs.py | 45 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index 9af5839..d8cce3b 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -1,16 +1,26 @@ -# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez +# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. -import os, sys, json +import os +import sys +import json + sys.path.insert(0, os.getcwd()) -import buildVars, sha256 +import buildVars +import sha256 def main(): addonId = buildVars.addon_info["addon_name"] readmeFile = os.path.join(os.getcwd(), "readme.md") i18nSources = sorted(buildVars.i18nSources) + readmeSha = None + i18nSourcesSha = None + shouldUpdateMd = False + shouldUpdatePot = False + shouldAddMdFromScratch = False + shouldAddPotFromScratch = False if os.path.isfile(readmeFile): readmeSha = sha256.sha256_checksum([readmeFile]) i18nSourcesSha = sha256.sha256_checksum(i18nSources) @@ -19,30 +29,31 @@ def main(): if os.path.isfile(hashFile): with open(hashFile, "rt") as f: data = json.load(f) - shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) - shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - shouldAddMdFromScratch = not os.path.isfile(hashFile) and not shouldUpdateMd - shouldAddPotFromScratch = not os.path.isfile(hashFile) and not shouldUpdatePot - data = dict() - if readmeSha: + shouldUpdateMd = data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None + shouldUpdatePot = ( + data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None + ) + shouldAddMdFromScratch = data.get("readmeSha") is None + shouldAddPotFromScratch = data.get("i18nSourcesSha") is None + if readmeSha is not None: data["readmeSha"] = readmeSha - if i18nSourcesSha: + if i18nSourcesSha is not None: data["i18nSourcesSha"] = i18nSourcesSha with open(hashFile, "wt", encoding="utf-8") as f: json.dump(data, f, indent="\t", ensure_ascii=False) - name = 'addonId' + name = "addonId" value = addonId - name0 = 'shouldUpdateMd' + name0 = "shouldUpdateMd" value0 = str(shouldUpdateMd).lower() - name1 = 'shouldUpdatePot' + name1 = "shouldUpdatePot" value1 = str(shouldUpdatePot).lower() - name2 = shouldAddMdFromScratch + name2 = "shouldAddMdFromScratch" value2 = str(shouldAddMdFromScratch).lower() - name3 = shouldAddPotFromScratch + name3 = "shouldAddPotFromScratch" value3 = str(shouldAddPotFromScratch).lower() - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") if __name__ == "__main__": - main() \ No newline at end of file + main() From 053d4de721cfcaf82bda92a3ea05125c63c3dae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:21:09 +0100 Subject: [PATCH 033/142] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4d01c88..2367c09 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout add-on uses: actions/checkout@v6 + with: + submodules: true - name: "Set up Python" uses: actions/setup-python@v6 with: @@ -27,40 +29,41 @@ jobs: pip install scons markdown sudo apt update sudo apt install gettext + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v7 - name: Build add-on and pot file run: | scons scons pot - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v7 - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | + echo "Yes" mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot + uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json + uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot + - name: Commit and push json id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add _l10n/l10n.json + git add *.json if git diff --staged --quiet; then echo "Nothing added to commit." else From 4a3f5a0dfbedeab8ce642b57b0ae0886adddf3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:39:49 +0100 Subject: [PATCH 034/142] Update lock --- uv.lock | 266 +------------------------------------------------------- 1 file changed, 1 insertion(+), 265 deletions(-) diff --git a/uv.lock b/uv.lock index 58c3f26..bda0207 100644 --- a/uv.lock +++ b/uv.lock @@ -1,267 +1,3 @@ version = 1 revision = 3 -requires-python = "==3.13.*" - -[[package]] -name = "addontemplate" -source = { editable = "." } -dependencies = [ - { name = "crowdin-api-client" }, - { name = "lxml" }, - { name = "markdown" }, - { name = "markdown-link-attr-modifier" }, - { name = "mdx-gh-links" }, - { name = "mdx-truly-sane-lists" }, - { name = "nh3" }, - { name = "requests" }, - { name = "scons" }, -] - -[package.metadata] -requires-dist = [ - { name = "crowdin-api-client", specifier = "==1.21.0" }, - { name = "lxml", specifier = ">=6.0.2" }, - { name = "markdown", specifier = ">=3.10" }, - { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, - { name = "mdx-gh-links", specifier = "==0.4" }, - { name = "mdx-truly-sane-lists", specifier = "==1.3" }, - { name = "nh3", specifier = "==0.2.19" }, - { name = "requests", specifier = ">=2.32.5" }, - { name = "scons", specifier = "==4.10.1" }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "crowdin-api-client" -version = "1.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/70/4069412e2e8004a6ad15bf2a3d9085bea50ee932a66ad935285831cf82b4/crowdin_api_client-1.21.0.tar.gz", hash = "sha256:0f957e5de6487a74ac892d524a5e300c1bc971320b67f85ce65741904420d8ec", size = 64729, upload-time = "2025-01-31T15:56:42.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/0f/9efc4f6db0b3b97f99015529b5832058ce4f7970d547b23fc04a38d69ddd/crowdin_api_client-1.21.0-py3-none-any.whl", hash = "sha256:85e19557755ebf6a15beda605d25b77de365244d8c636462b0dd8030a6cdfe20", size = 101566, upload-time = "2025-01-31T15:56:40.651Z" }, -] - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, -] - -[[package]] -name = "markdown" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, -] - -[[package]] -name = "markdown-link-attr-modifier" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, -] - -[[package]] -name = "mdx-gh-links" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, -] - -[[package]] -name = "mdx-truly-sane-lists" -version = "1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, -] - -[[package]] -name = "nh3" -version = "0.2.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/32/3b8d8471d006333bac3175ad37402414d985ed3f8650a01a33e0e86b9824/nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804", size = 17327, upload-time = "2024-11-30T04:05:56.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1d/cbd75a2313d96cd3903111667d3d07548fb45c8ecf5c315f37a8f6c202fa/nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6", size = 1205181, upload-time = "2024-11-29T05:50:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/f0/30/8e9ec472ce575fa6b98935920c91df637bf9342862bd943745441aec99eb/nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0", size = 739174, upload-time = "2024-11-29T05:50:10.157Z" }, - { url = "https://files.pythonhosted.org/packages/5c/b5/d1f81c5ec5695464b69d8aa4529ecb5fd872cbfb29f879b4063bb9397da8/nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37", size = 758660, upload-time = "2024-11-29T05:50:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/295a3a069f3b9dc35527eedd7b212f31311ef1f66a0e5f5f0acad6db9456/nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a", size = 924377, upload-time = "2024-11-29T05:50:15.716Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/0f189d5054f22cdfdb16d16a2a41282f411a4c03f8418be47e0480bd5bfd/nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd", size = 992124, upload-time = "2024-11-29T05:50:17.637Z" }, - { url = "https://files.pythonhosted.org/packages/0d/87/2907edd61a2172527c5322036aa95ce6c18432ff280fc5cf78fe0f934c65/nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9", size = 913939, upload-time = "2024-11-29T05:50:20.435Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/e0d3ea0175f28032d7d2bab765250f4e94ef131a7b3293e3df4cb254a5b2/nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55", size = 909051, upload-time = "2024-11-29T05:50:31.116Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/f52cb1d54ba965b7d8bb1c884ca982be31d7f75ad9e7e5817f4af20002b3/nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc", size = 540566, upload-time = "2024-11-29T05:50:38.437Z" }, - { url = "https://files.pythonhosted.org/packages/70/85/91a66edfab0adbf22468973d8abd4b93c951bbcbbe2121675ee468b912a2/nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3", size = 542368, upload-time = "2024-11-29T05:50:41.418Z" }, - { url = "https://files.pythonhosted.org/packages/32/9c/f8808cf6683d4852ba8862e25b98aa9116067ddec517938a1b6e8faadb43/nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41", size = 1205140, upload-time = "2024-11-29T05:50:54.582Z" }, - { url = "https://files.pythonhosted.org/packages/04/0e/268401d9244a84935342d9f3ba5d22bd7d2fc10cfc7a8f59bde8f6721466/nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a", size = 763571, upload-time = "2024-11-29T05:51:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/8be706feb6637d6e5db0eed09fd3f4e1008aee3d5d7161c9973d7aae1d13/nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69", size = 750319, upload-time = "2024-11-29T05:51:05.512Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ce/1f5f9ba0194f6a882e4bda89ae831678e4b68aa3de91e11e2629a1e6a613/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9", size = 857636, upload-time = "2024-11-29T05:51:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/5661a66f2950879a81fde5fbb6beb650c5647776aaec1a676e6b3ff4b6e5/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c", size = 821081, upload-time = "2024-11-29T05:51:08.202Z" }, - { url = "https://files.pythonhosted.org/packages/22/f8/454828f6f21516bf0c8c578e8bc2ab4f045e6b6fe5179602fe4dc2479da6/nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1", size = 894452, upload-time = "2024-11-29T05:51:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5d/36f5b78cbc631cac1c993bdc4608a0fe3148214bdb6d2c1266e228a2686a/nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc", size = 748281, upload-time = "2024-11-29T05:51:29.021Z" }, - { url = "https://files.pythonhosted.org/packages/98/da/d04f5f0e7ee8edab8ceecdbba9f1c614dc8cf07374141ff6ea3b615b3479/nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707", size = 767109, upload-time = "2024-11-29T05:51:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5b/1232fb35c7d1182adb7d513fede644a81b5361259749781e6075c40a9125/nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22", size = 924295, upload-time = "2024-11-29T05:51:33.078Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fd/ae622d08518fd31360fd87a515700bc09913f2e57e7f010063f2193ea610/nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d", size = 992038, upload-time = "2024-11-29T05:51:34.414Z" }, - { url = "https://files.pythonhosted.org/packages/56/78/226577c5e3fe379cb95265aa77736e191d859032c974169e6879c51c156f/nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9", size = 913866, upload-time = "2024-11-29T05:51:35.727Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ca/bbd2b2dab31ceae38cfa673861cab81df5ed5be1fe47b6c4f5aa41729aa2/nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0", size = 908976, upload-time = "2024-11-29T05:51:43.698Z" }, - { url = "https://files.pythonhosted.org/packages/4c/8f/6452eb1184ad87cdd2cac7ee3ebd67a2aadb554d25572c1778efdf807e1e/nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48", size = 540528, upload-time = "2024-11-29T05:51:45.312Z" }, - { url = "https://files.pythonhosted.org/packages/58/d6/285df10307f16fcce9afbd133b04b4bc7d7f9b84b02f0f724bab30dacdd9/nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121", size = 542316, upload-time = "2024-11-29T05:52:01.253Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "scons" -version = "4.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, -] +requires-python = ">=3.13" From dbe74dcad03276485e697e9ee767ac355e8d7f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:04:21 +0100 Subject: [PATCH 035/142] Verify uv lock --- .pre-commit-config.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c8f5c6..8353208 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,10 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff - + - id: uv-lock + name: Verify uv lock file + # Override python interpreter from .python-versions as that is too strict for pre-commit.ci + args: ["-p3.13"] - repo: local hooks: From e717292a628eade7edf31683f8f6d30c1d861186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:06:53 +0100 Subject: [PATCH 036/142] Add uv to dependencies in case this is relevant to verify the lock according to uv version --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6178665..4441e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,5 @@ dependencies = [ + "uv==0.9.11", "SCons==4.10.1", "Markdown==3.10", "ruff==0.14.5", From c4ed57508a8ca3f859cb77b5aa743b197ed24620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:10:54 +0100 Subject: [PATCH 037/142] Remove debug statement --- .github/workflows/exportAddonToCrowdin.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 2367c09..1096152 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -41,7 +41,6 @@ jobs: - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | - echo "Yes" mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md From befa647e2e7d2b6821a79b2f787ae2fc5675e10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:17:39 +0100 Subject: [PATCH 038/142] Run pre-commit --- .github/workflows/exportAddonToCrowdin.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1096152..7ca8edb 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -23,18 +23,20 @@ jobs: uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install dependencies + - name: Install gettext run: | - python -m pip install --upgrade pip - pip install scons markdown sudo apt update sudo apt install gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Run pre-commit + run: | + # Ensure uv environment is up to date. + uv run pre-commit run uv-lock --all-files - name: Build add-on and pot file run: | - scons - scons pot + uv run scons + uv run scons pot - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py From 05c816196c41b150cd1007f94ce3830602466abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:01:01 +0100 Subject: [PATCH 039/142] Update dependencies --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4441e1e..f3054a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,6 @@ dependencies = [ - "uv==0.9.11", "SCons==4.10.1", "Markdown==3.10", - "ruff==0.14.5", - "pre-commit==4.2.0", - "pyright[nodejs]==1.1.407", ] [tool.ruff] line-length = 110 From 9a0f62abbc2be3a9528935c6585ae934bbdf5af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:29:16 +0100 Subject: [PATCH 040/142] Deleted Pyproject to avoid conflicts --- pyproject.toml | 165 ------------------------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f3054a0..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,165 +0,0 @@ -dependencies = [ - "SCons==4.10.1", - "Markdown==3.10", -] -[tool.ruff] -line-length = 110 - -builtins = [ - # translation lookup - "_", - # translation lookup - "ngettext", - # translation lookup - "pgettext", - # translation lookup - "npgettext", -] - -include = [ - "*.py", - "sconstruct", -] - -exclude = [ - ".git", - "__pycache__", -] - -[tool.ruff.format] -indent-style = "tab" - -[tool.ruff.lint.mccabe] -max-complexity = 15 - -[tool.ruff.lint] -ignore = [ - # indentation contains tabs - "W191", -] - -[tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, -# so ignore F821. -"sconstruct" = ["F821"] - -[tool.pyright] -pythonPlatform = "Windows" -typeCheckingMode = "strict" - -include = [ - "**/*.py", -] - -exclude = [ - "sconstruct", - ".git", - "__pycache__", - # When excluding concrete paths relative to a directory, - # not matching multiple folders by name e.g. `__pycache__`, - # paths are relative to the configuration file. -] - -# Tell pyright where to load python code from -extraPaths = [ - "./addon", -] - -# General config -analyzeUnannotatedFunctions = true -deprecateTypingAliases = true - -# Stricter typing -strictParameterNoneValue = true -strictListInference = true -strictDictionaryInference = true -strictSetInference = true - -# Compliant rules -reportAbstractUsage = true -reportArgumentType = true -reportAssertAlwaysTrue = true -reportAssertTypeFailure = true -reportAssignmentType = true -reportAttributeAccessIssue = true -reportCallInDefaultInitializer = true -reportCallIssue = true -reportConstantRedefinition = true -reportDuplicateImport = true -reportFunctionMemberAccess = true -reportGeneralTypeIssues = true -reportImplicitOverride = true -reportImplicitStringConcatenation = true -reportImportCycles = true -reportIncompatibleMethodOverride = true -reportIncompatibleVariableOverride = true -reportIncompleteStub = true -reportInconsistentConstructor = true -reportInconsistentOverload = true -reportIndexIssue = true -reportInvalidStringEscapeSequence = true -reportInvalidStubStatement = true -reportInvalidTypeArguments = true -reportInvalidTypeForm = true -reportInvalidTypeVarUse = true -reportMatchNotExhaustive = true -reportMissingImports = true -reportMissingModuleSource = true -reportMissingParameterType = true -reportMissingSuperCall = true -reportMissingTypeArgument = true -reportNoOverloadImplementation = true -reportOperatorIssue = true -reportOptionalCall = true -reportOptionalContextManager = true -reportOptionalIterable = true -reportOptionalMemberAccess = true -reportOptionalOperand = true -reportOptionalSubscript = true -reportOverlappingOverload = true -reportPossiblyUnboundVariable = true -reportPrivateImportUsage = true -reportPrivateUsage = true -reportPropertyTypeMismatch = true -reportRedeclaration = true -reportReturnType = true -reportSelfClsParameterName = true -reportShadowedImports = true -reportTypeCommentUsage = true -reportTypedDictNotRequiredAccess = true -reportUnboundVariable = true -reportUndefinedVariable = true -reportUnhashable = true -reportUninitializedInstanceVariable = true -reportUnknownArgumentType = true -reportUnknownLambdaType = true -reportUnknownMemberType = true -reportUnknownParameterType = true -reportUnknownVariableType = true -reportUnnecessaryCast = true -reportUnnecessaryComparison = true -reportUnnecessaryContains = true -reportUnnecessaryIsInstance = true -reportUnnecessaryTypeIgnoreComment = true -reportUnsupportedDunderAll = true -reportUntypedBaseClass = true -reportUntypedClassDecorator = true -reportUntypedFunctionDecorator = true -reportUntypedNamedTuple = true -reportUnusedCallResult = true -reportUnusedClass = true -reportUnusedCoroutine = true -reportUnusedExcept = true -reportUnusedExpression = true -reportUnusedFunction = true -reportUnusedImport = true -reportUnusedVariable = true -reportWildcardImportFromLibrary = true - -reportDeprecated = true - -# Can be enabled by generating type stubs for modules via pyright CLI -reportMissingTypeStubs = false - -# Bad rules -# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 4abd788013e44b6522ce422c7e3678582ae66047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:30:18 +0100 Subject: [PATCH 041/142] Reset pyproject to master --- pyproject.toml | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97189ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,161 @@ +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", +] + +[tool.ruff.format] +indent-style = "tab" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] + +[tool.ruff.lint.per-file-ignores] +# sconstruct contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportShadowedImports = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From c25636475dfb6e5367495d06c9b075ee4a1522fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:36:16 +0100 Subject: [PATCH 042/142] Remove _l10n since this will be added as a submodule --- _l10n/crowdinSync.py | 92 ---- _l10n/l10nUtil.py | 951 ------------------------------------- _l10n/markdownTranslate.py | 737 ---------------------------- _l10n/md2html.py | 197 -------- 4 files changed, 1977 deletions(-) delete mode 100644 _l10n/crowdinSync.py delete mode 100644 _l10n/l10nUtil.py delete mode 100644 _l10n/markdownTranslate.py delete mode 100644 _l10n/md2html.py diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py deleted file mode 100644 index e879bba..0000000 --- a/_l10n/crowdinSync.py +++ /dev/null @@ -1,92 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# based on file from https://github.com/jcsteh/osara -# Copyright (C) 2023-2025 NV Access Limited, James Teh -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - - -import argparse -import os - -import requests - -from l10nUtil import getFiles - -AUTH_TOKEN = os.getenv("crowdinAuthToken", "").strip() -if not AUTH_TOKEN: - raise ValueError("crowdinAuthToken environment variable not set") -PROJECT_ID = os.getenv("crowdinProjectID", "").strip() -if not PROJECT_ID: - raise ValueError("crowdinProjectID environment variable not set") - - -def request( - path: str, - method=requests.get, - headers: dict[str, str] | None = None, - **kwargs, -) -> requests.Response: - if headers is None: - headers = {} - headers["Authorization"] = f"Bearer {AUTH_TOKEN}" - r = method( - f"https://api.crowdin.com/api/v2/{path}", - headers=headers, - **kwargs, - ) - # Convert errors to exceptions, but print the response before raising. - try: - r.raise_for_status() - except requests.exceptions.HTTPError: - print(r.json()) - raise - return r - - -def projectRequest(path: str, **kwargs) -> requests.Response: - return request(f"projects/{PROJECT_ID}/{path}", **kwargs) - - -def uploadSourceFile(localFilePath: str) -> None: - files = getFiles() - fn = os.path.basename(localFilePath) - crowdinFileID = files.get(fn) - print(f"Uploading {localFilePath} to Crowdin temporary storage as {fn}") - with open(localFilePath, "rb") as f: - r = request( - "storages", - method=requests.post, - headers={"Crowdin-API-FileName": fn}, - data=f, - ) - storageID = r.json()["data"]["id"] - print(f"Updating file {crowdinFileID} on Crowdin with storage ID {storageID}") - r = projectRequest( - f"files/{crowdinFileID}", - method=requests.put, - json={"storageId": storageID}, - ) - revisionId = r.json()["data"]["revisionId"] - print(f"Updated to revision {revisionId}") - - -def main(): - parser = argparse.ArgumentParser( - description="Syncs translations with Crowdin.", - ) - commands = parser.add_subparsers(dest="command", required=True) - uploadCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - - uploadCommand.add_argument("localFilePath", help="The path to the local file.") - args = parser.parse_args() - if args.command == "uploadSourceFile": - uploadSourceFile(args.localFilePath) - else: - raise ValueError(f"Unknown command: {args.command}") - - -if __name__ == "__main__": - main() diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py deleted file mode 100644 index 00dab1a..0000000 --- a/_l10n/l10nUtil.py +++ /dev/null @@ -1,951 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -import os, sys -sys.path.insert(0, os.getcwd()) - -import crowdin_api as crowdin -import tempfile -import lxml.etree -import os -import shutil -import argparse -import markdownTranslate -import requests -import codecs -import re -import subprocess -import sys -import zipfile -import time -import json - -import buildVars - - -CROWDIN_PROJECT_ID = os.getenv("crowdinProjectID", "").strip() -POLLING_INTERVAL_SECONDS = 5 -EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") - - -def fetchCrowdinAuthToken() -> str: - """ - Fetch the Crowdin auth token from the ~/.nvda_crowdin file or prompt the user for it. - If provided by the user, the token will be saved to the ~/.nvda_crowdin file. - :return: The auth token - """ - crowdinAuthToken = os.getenv("crowdinAuthToken", "") - if crowdinAuthToken: - print("Using Crowdin auth token from environment variable.") - return crowdinAuthToken - token_path = os.path.expanduser("~/.nvda_crowdin") - if os.path.exists(token_path): - with open(token_path, "r") as f: - token = f.read().strip() - print("Using auth token from ~/.nvda_crowdin") - return token - print("A Crowdin auth token is required to proceed.") - print("Please visit https://crowdin.com/settings#api-key") - print("Create a personal access token with translations permissions, and enter it below.") - token = input("Enter Crowdin auth token: ").strip() - with open(token_path, "w") as f: - f.write(token) - return token - - -_crowdinClient = None - - -def getCrowdinClient() -> crowdin.CrowdinClient: - """ - Create or fetch the Crowdin client instance. - :return: The Crowdin client - """ - global _crowdinClient - if _crowdinClient is None: - token = fetchCrowdinAuthToken() - _crowdinClient = crowdin.CrowdinClient(project_id=CROWDIN_PROJECT_ID, token=token) - return _crowdinClient - - -def fetchLanguageFromXliff(xliffPath: str, source: bool = False) -> str: - """ - Fetch the language from an xliff file. - This function also prints a message to the console stating the detected language if found, or a warning if not found. - :param xliffPath: Path to the xliff file - :param source: If True, fetch the source language, otherwise fetch the target language - :return: The language code - """ - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - lang = xliffRoot.get("srcLang" if source else "trgLang") - if lang is None: - print(f"Could not detect language for xliff file {xliffPath}, {source=}") - else: - print(f"Detected language {lang} for xliff file {xliffPath}, {source=}") - return lang - - -def preprocessXliff(xliffPath: str, outputPath: str): - """ - Replace corrupt or empty translated segment targets with the source text, - marking the segment again as "initial" state. - This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. - :param xliffPath: Path to the xliff file to be processed - :param outputPath: Path to the resulting xliff file - """ - print(f"Preprocessing xliff file at {xliffPath}") - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - file = xliffRoot.find("./xliff:file", namespaces=namespace) - units = file.findall("./xliff:unit", namespaces=namespace) - segmentCount = 0 - emptyTargetCount = 0 - corruptTargetcount = 0 - for unit in units: - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - print("Warning: No segment element in unit") - continue - source = segment.find("./xliff:source", namespaces=namespace) - if source is None: - print("Warning: No source element in segment") - continue - sourceText = source.text - segmentCount += 1 - target = segment.find("./xliff:target", namespaces=namespace) - if target is None: - continue - targetText = target.text - # Correct empty targets - if not targetText: - emptyTargetCount += 1 - target.text = sourceText - segment.set("state", "initial") - # Correct corrupt target tags - elif targetText in ( - "", - "<target/>", - "", - "<target></target>", - ): - corruptTargetcount += 1 - target.text = sourceText - segment.set("state", "initial") - xliff.write(outputPath, encoding="utf-8") - print( - f"Processed {segmentCount} segments, removing {emptyTargetCount} empty targets, {corruptTargetcount} corrupt targets", - ) - - -def stripXliff(xliffPath: str, outputPath: str, oldXliffPath: str | None = None): - """ - Removes notes and skeleton elements from an xliff file before upload to Crowdin. - Removes empty and corrupt translations. - Removes untranslated segments. - Removes existing translations if an old xliff file is provided. - This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. - :param xliffPath: Path to the xliff file to be stripped - :param outputPath: Path to the resulting xliff file - :param oldXliffPath: Path to the old xliff file containing existing translations that should be also stripped. - """ - print(f"Creating stripped xliff at {outputPath} from {xliffPath}") - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - oldXliffRoot = None - if oldXliffPath: - oldXliff = lxml.etree.parse(oldXliffPath) - oldXliffRoot = oldXliff.getroot() - if oldXliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {oldXliffPath}") - skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) - if skeletonNode is not None: - skeletonNode.getparent().remove(skeletonNode) - file = xliffRoot.find("./xliff:file", namespaces=namespace) - units = file.findall("./xliff:unit", namespaces=namespace) - segmentCount = 0 - untranslatedCount = 0 - emptyCount = 0 - corruptCount = 0 - existingTranslationCount = 0 - for unit in units: - unitID = unit.get("id") - notes = unit.find("./xliff:notes", namespaces=namespace) - if notes is not None: - unit.remove(notes) - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - print("Warning: No segment element in unit") - continue - segmentCount += 1 - state = segment.get("state") - if state == "initial": - file.remove(unit) - untranslatedCount += 1 - continue - target = segment.find("./xliff:target", namespaces=namespace) - if target is None: - file.remove(unit) - untranslatedCount += 1 - continue - targetText = target.text - if not targetText: - emptyCount += 1 - file.remove(unit) - continue - elif targetText in ( - "", - "<target/>", - "", - "<target></target>", - ): - corruptCount += 1 - file.remove(unit) - continue - if oldXliffRoot: - # Remove existing translations - oldTarget = oldXliffRoot.find( - f"./xliff:file/xliff:unit[@id='{unitID}']/xliff:segment/xliff:target", - namespaces=namespace, - ) - if oldTarget is not None and oldTarget.getparent().get("state") != "initial": - if oldTarget.text == targetText: - file.remove(unit) - existingTranslationCount += 1 - xliff.write(outputPath, encoding="utf-8") - if corruptCount > 0: - print(f"Removed {corruptCount} corrupt translations.") - if emptyCount > 0: - print(f"Removed {emptyCount} empty translations.") - if existingTranslationCount > 0: - print(f"Ignored {existingTranslationCount} existing translations.") - keptTranslations = segmentCount - untranslatedCount - emptyCount - corruptCount - existingTranslationCount - print(f"Added or changed {keptTranslations} translations.") - - -def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): - """ - Download a translation file from Crowdin. - :param crowdinFilePath: The Crowdin file path - :param localFilePath: The path to save the local file - :param language: The language code to download the translation for - """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(crowdinFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(crowdinFilePath) - print(f"Requesting export of {crowdinFilePath} for {language} from Crowdin") - res = getCrowdinClient().translations.export_project_translation( - fileIds=[fileId], - targetLanguageId=language, - ) - if res is None: - raise ValueError("Crowdin export failed") - download_url = res["data"]["url"] - print(f"Downloading from {download_url}") - with open(localFilePath, "wb") as f: - r = requests.get(download_url) - f.write(r.content) - print(f"Saved to {localFilePath}") - - -def uploadSourceFile(localFilePath: str): - """ - Upload a source file to Crowdin. - :param localFilePath: The path to the local file to be uploaded - """ - if not os.path.isfile(L10N_FILE): - getFiles() - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(localFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(localFilePath) - res = getCrowdinClient().storages.add_storage( - open(localFilePath, "rb"), - ) - if res is None: - raise ValueError("Crowdin storage upload failed") - storageId = res["data"]["id"] - print(f"Stored with ID {storageId}") - filename = os.path.basename(localFilePath) - fileId = files.get(filename) - print(f"File ID: {fileId}") - match fileId: - case None: - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = ( - f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - ) - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Exporting source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - print("Done") - case _: - res = getCrowdinClient().source_files.update_file( - fileId=fileId, - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - ) - - - -def getFiles() -> dict[str, int]: - """Gets files from Crowdin, and write them to a json file.""" - - addonId = buildVars.addon_info["addon_name"] - - res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) - if res is None: - raise ValueError("Getting files from Crowdin failed") - dictionary = dict() - data = res["data"] - for file in data: - fileInfo = file["data"] - name = fileInfo["name"] - id = fileInfo["id"] - dictionary[name] = id - with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: - json.dump(dictionary, jsonFile, ensure_ascii=False) - return dictionary - - -def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): - """ - Upload a translation file to Crowdin. - :param crowdinFilePath: The Crowdin file path - :param localFilePath: The path to the local file to be uploaded - :param language: The language code to upload the translation for - """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(crowdinFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(crowdinFilePath) - print(f"Uploading {localFilePath} to Crowdin") - res = getCrowdinClient().storages.add_storage( - open(localFilePath, "rb"), - ) - if res is None: - raise ValueError("Crowdin storage upload failed") - storageId = res["data"]["id"] - print(f"Stored with ID {storageId}") - print(f"Importing translation for {crowdinFilePath} in {language} from storage with ID {storageId}") - res = getCrowdinClient().translations.upload_translation( - fileId=fileId, - languageId=language, - storageId=storageId, - autoApproveImported=True, - importEqSuggestions=True, - ) - print("Done") - - -def exportTranslations(outputDir: str, language: str | None = None): - """ - Export translation files from Crowdin as a bundle. - :param outputDir: Directory to save translation files. - :param language: The language code to export (e.g., 'es', 'fr', 'de'). - If None, exports all languages. - """ - - # Create output directory if it doesn't exist - os.makedirs(outputDir, exist_ok=True) - - client = getCrowdinClient() - - requestData = { - "skipUntranslatedStrings": False, - "skipUntranslatedFiles": True, - "exportApprovedOnly": False, - } - - if language is not None: - requestData["targetLanguageIds"] = [language] - - if language is None: - print("Requesting export of all translations from Crowdin...") - else: - print(f"Requesting export of all translations for language: {language}") - build_res = client.translations.build_project_translation(request_data=requestData) - - if language is None: - zip_filename = "translations.zip" - else: - zip_filename = f"translations_{language}.zip" - - if build_res is None: - raise ValueError("Failed to start translation build") - - build_id = build_res["data"]["id"] - print(f"Build started with ID: {build_id}") - - # Wait for the build to complete - print("Waiting for build to complete...") - while True: - status_res = client.translations.check_project_build_status(build_id) - if status_res is None: - raise ValueError("Failed to check build status") - - status = status_res["data"]["status"] - progress = status_res["data"]["progress"] - print(f"Build status: {status} ({progress}%)") - - if status == "finished": - break - elif status == "failed": - raise ValueError("Translation build failed") - - time.sleep(POLLING_INTERVAL_SECONDS) - - # Download the completed build - print("Downloading translations archive...") - download_res = client.translations.download_project_translations(build_id) - if download_res is None: - raise ValueError("Failed to get download URL") - - download_url = download_res["data"]["url"] - print(f"Downloading from {download_url}") - - # Download and extract the ZIP file - zip_path = os.path.join(outputDir, zip_filename) - response = requests.get(download_url, stream=True, timeout=EXPORT_TIMEOUT_SECONDS) - response.raise_for_status() - - with open(zip_path, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - - print(f"Archive saved to {zip_path}") - print("Extracting translations...") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(outputDir) - - # Remove the zip file - os.remove(zip_path) - - if language is None: - print(f"\nExport complete! All translations extracted to '{outputDir}' directory.") - else: - print(f"\nExport complete! All {language} translations extracted to '{outputDir}' directory.") - - -class _PoChecker: - """Checks a po file for errors not detected by msgfmt. - This first runs msgfmt to check for syntax errors. - It then checks for mismatched Python percent and brace interpolations. - Construct an instance and call the L{check} method. - """ - - FUZZY = "#, fuzzy" - MSGID = "msgid" - MSGID_PLURAL = "msgid_plural" - MSGSTR = "msgstr" - - def __init__(self, po: str): - """Constructor. - :param po: The path to the po file to check. - """ - self._poPath = po - with codecs.open(po, "r", "utf-8") as file: - self._poContent = file.readlines() - self._string: str | None = None - - self.alerts: list[str] = [] - """List of error and warning messages found in the po file.""" - - self.hasSyntaxError: bool = False - """Whether there is a syntax error in the po file.""" - - self.warningCount: int = 0 - """Number of warnings found.""" - - self.errorCount: int = 0 - """Number of errors found.""" - - def _addToString(self, line: list[str], startingCommand: str | None = None) -> None: - """Helper function to add a line to the current string. - :param line: The line to add. - :param startingCommand: The command that started this string, if any. - This is used to determine whether to strip the command and quotes. - """ - if startingCommand: - # Strip the command and the quotes. - self._string = line[len(startingCommand) + 2 : -1] - else: - # Strip the quotes. - self._string += line[1:-1] - - def _finishString(self) -> str: - """Helper function to finish the current string. - :return: The finished string. - """ - string = self._string - self._string = None - return string - - def _messageAlert(self, alert: str, isError: bool = True) -> None: - """Helper function to add an alert about a message. - :param alert: The alert message. - :param isError: Whether this is an error or a warning. - """ - if self._fuzzy: - # Fuzzy messages don't get used, so this shouldn't be considered an error. - isError = False - if isError: - self.errorCount += 1 - else: - self.warningCount += 1 - if self._fuzzy: - msgType = "Fuzzy message" - else: - msgType = "Message" - self.alerts.append( - f"{msgType} starting on line {self._messageLineNum}\n" - f'Original: "{self._msgid}"\n' - f'Translated: "{self._msgstr[-1]}"\n' - f"{'ERROR' if isError else 'WARNING'}: {alert}", - ) - - @property - def MSGFMT_PATH(self) -> str: - try: - # When running from source, miscDeps is the sibling of parent this script. - _MSGFMT = os.path.join(os.path.dirname(__file__), "..", "miscDeps", "tools", "msgfmt.exe") - except NameError: - # When running from a frozen executable, __file__ is not defined. - # In this case, we use the distribution path. - # When running from a distribution, source/l10nUtil.py is built to l10nUtil.exe. - # miscDeps is the sibling of this script in the distribution. - _MSGFMT = os.path.join(sys.prefix, "miscDeps", "tools", "msgfmt.exe") - - if not os.path.exists(_MSGFMT): - raise FileNotFoundError( - "msgfmt executable not found. " - "Please ensure that miscDeps/tools/msgfmt.exe exists in the source tree or distribution.", - ) - return _MSGFMT - - def _checkSyntax(self) -> None: - """Check the syntax of the po file using msgfmt. - This will set the hasSyntaxError attribute to True if there is a syntax error. - """ - - result = subprocess.run( - (self.MSGFMT_PATH, "-o", "-", self._poPath), - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, # Ensures stderr is a text stream - ) - if result.returncode != 0: - output = result.stderr.rstrip().replace("\r\n", "\n") - self.alerts.append(output) - self.hasSyntaxError = True - self.errorCount = 1 - - def _checkMessages(self) -> None: - command = None - self._msgid = None - self._msgid_plural = None - self._msgstr = None - nextFuzzy = False - self._fuzzy = False - for lineNum, line in enumerate(self._poContent, 1): - line = line.strip() - if line.startswith(self.FUZZY): - nextFuzzy = True - continue - elif line.startswith(self.MSGID) and not line.startswith(self.MSGID_PLURAL): - # New message. - if self._msgstr is not None: - self._msgstr[-1] = self._finishString() - # Check the message we just handled. - self._checkMessage() - command = self.MSGID - start = command - self._messageLineNum = lineNum - self._fuzzy = nextFuzzy - nextFuzzy = False - elif line.startswith(self.MSGID_PLURAL): - self._msgid = self._finishString() - command = self.MSGID_PLURAL - start = command - elif line.startswith(self.MSGSTR): - self._handleMsgStrReaching(lastCommand=command) - command = self.MSGSTR - start = line[: line.find(" ")] - elif line.startswith('"'): - # Continuing a string. - start = None - else: - # This line isn't of interest. - continue - self._addToString(line, startingCommand=start) - if command == self.MSGSTR: - # Handle the last message. - self._msgstr[-1] = self._finishString() - self._checkMessage() - - def _handleMsgStrReaching(self, lastCommand: str) -> None: - """Helper function used by _checkMessages to handle the required processing when reaching a line - starting with "msgstr". - :param lastCommand: the current command just before the msgstr line is reached. - """ - - # Finish the string of the last command and check the message if it was an msgstr - if lastCommand == self.MSGID: - self._msgid = self._finishString() - elif lastCommand == self.MSGID_PLURAL: - self._msgid_plural = self._finishString() - elif lastCommand == self.MSGSTR: - self._msgstr[-1] = self._finishString() - self._checkMessage() - else: - raise RuntimeError(f"Unexpected command before line {self._messageLineNum}: {lastCommand}") - - # For first msgstr create the msgstr list - if lastCommand != self.MSGSTR: - self._msgstr = [] - - # Initiate the string for the current msgstr - self._msgstr.append("") - - def check(self) -> bool: - """Check the file. - Once this returns, you can call getReport to obtain a report. - This method should not be called more than once. - :return: True if the file is okay, False if there were problems. - """ - self._checkSyntax() - if self.alerts: - return False - self._checkMessages() - if self.alerts: - return False - return True - - # e.g. %s %d %10.2f %-5s (but not %%) or %%(name)s %(name)d - RE_UNNAMED_PERCENT = re.compile( - # Does not include optional mapping key, as that's handled by a different regex - r""" - (?:(?<=%%)|(? tuple[list[str], set[str], set[str]]: - """Get the percent and brace interpolations in a string. - :param text: The text to check. - :return: A tuple of a list and two sets: - - unnamed percent interpolations (e.g. %s, %d) - - named percent interpolations (e.g. %(name)s) - - brace format interpolations (e.g. {name}, {name:format}) - """ - unnamedPercent = self.RE_UNNAMED_PERCENT.findall(text) - namedPercent = set(self.RE_NAMED_PERCENT.findall(text)) - formats = set() - for m in self.RE_FORMAT.finditer(text): - if not m.group(1): - self._messageAlert( - "Unspecified positional argument in brace format", - # Skip as error as many of these had been introduced in the source .po files. - # These should be fixed in the source .po files to add names to instances of "{}". - # This causes issues where the order of the arguments change in the string. - # e.g. "Character: {}\nReplacement: {}" being translated to "Replacement: {}\nCharacter: {}" - # will result in the expected interpolation being in the wrong place. - # This should be changed isError=True. - isError=False, - ) - formats.add(m.group(0)) - return unnamedPercent, namedPercent, formats - - def _formatInterpolations( - self, - unnamedPercent: list[str], - namedPercent: set[str], - formats: set[str], - ) -> str: - """Format the interpolations for display in an error message. - :param unnamedPercent: The unnamed percent interpolations. - :param namedPercent: The named percent interpolations. - :param formats: The brace format interpolations. - """ - out: list[str] = [] - if unnamedPercent: - out.append(f"unnamed percent interpolations in this order: {unnamedPercent}") - if namedPercent: - out.append(f"these named percent interpolations: {namedPercent}") - if formats: - out.append(f"these brace format interpolations: {formats}") - if not out: - return "no interpolations" - return "\n\tAnd ".join(out) - - def _checkMessage(self) -> None: - idUnnamedPercent, idNamedPercent, idFormats = self._getInterpolations(self._msgid) - if not self._msgstr[-1]: - return - strUnnamedPercent, strNamedPercent, strFormats = self._getInterpolations(self._msgstr[-1]) - error = False - alerts = [] - if idUnnamedPercent != strUnnamedPercent: - if idUnnamedPercent: - alerts.append("unnamed percent interpolations differ") - error = True - else: - alerts.append("unexpected presence of unnamed percent interpolations") - if idNamedPercent - strNamedPercent: - alerts.append("missing named percent interpolation") - error = True - if strNamedPercent - idNamedPercent: - if idNamedPercent: - alerts.append("extra named percent interpolation") - error = True - else: - alerts.append("unexpected presence of named percent interpolations") - if idFormats - strFormats: - alerts.append("missing brace format interpolation") - error = True - if strFormats - idFormats: - if idFormats: - alerts.append("extra brace format interpolation") - error = True - else: - alerts.append("unexpected presence of brace format interpolations") - if alerts: - self._messageAlert( - f"{', '.join(alerts)}\n" - f"Expected: {self._formatInterpolations(idUnnamedPercent, idNamedPercent, idFormats)}\n" - f"Got: {self._formatInterpolations(strUnnamedPercent, strNamedPercent, strFormats)}", - isError=error, - ) - - def getReport(self) -> str | None: - """Get a text report about any errors or warnings. - :return: The text or None if there were no problems. - """ - if not self.alerts: - return None - report = f"File {self._poPath}: " - if self.hasSyntaxError: - report += "syntax error" - else: - if self.errorCount: - msg = "error" if self.errorCount == 1 else "errors" - report += f"{self.errorCount} {msg}" - if self.warningCount: - if self.errorCount: - report += ", " - msg = "warning" if self.warningCount == 1 else "warnings" - report += f"{self.warningCount} {msg}" - report += "\n\n" + "\n\n".join(self.alerts) - return report - - -def checkPo(poFilePath: str) -> tuple[bool, str | None]: - """Check a po file for errors. - :param poFilePath: The path to the po file to check. - :return: - True if the file is okay or has warnings, False if there were fatal errors. - A report about the errors or warnings found, or None if there were no problems. - """ - c = _PoChecker(poFilePath) - report = None - if not c.check(): - report = c.getReport() - if report: - report = report.encode("cp1252", errors="backslashreplace").decode( - "utf-8", - errors="backslashreplace", - ) - return not bool(c.errorCount), report - - -def main(): - args = argparse.ArgumentParser() - commands = args.add_subparsers(title="commands", dest="command", required=True) - command_checkPo = commands.add_parser("checkPo", help="Check po files") - # Allow entering arbitrary po file paths, not just those in the source tree - command_checkPo.add_argument( - "poFilePaths", - help="Paths to the po file to check", - nargs="+", - ) - command_xliff2md = commands.add_parser("xliff2md", help="Convert xliff to markdown") - command_xliff2md.add_argument( - "-u", - "--untranslated", - help="Produce the untranslated markdown file", - action="store_true", - default=False, - ) - command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") - command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - downloadTranslationFileCommand = commands.add_parser( - "downloadTranslationFile", - help="Download a translation file from Crowdin.", - ) - downloadTranslationFileCommand.add_argument( - "language", - help="The language code to download the translation for.", - ) - downloadTranslationFileCommand.add_argument( - "crowdinFilePath", - help="The Crowdin file path", - ) - downloadTranslationFileCommand.add_argument( - "localFilePath", - nargs="?", - default=None, - help="The path to save the local file. If not provided, the Crowdin file path will be used.", - ) - uploadTranslationFileCommand = commands.add_parser( - "uploadTranslationFile", - help="Upload a translation file to Crowdin.", - ) - uploadTranslationFileCommand.add_argument( - "-o", - "--old", - help="Path to the old unchanged xliff file. If provided, only new or changed translations will be uploaded.", - default=None, - ) - uploadTranslationFileCommand.add_argument( - "language", - help="The language code to upload the translation for.", - ) - uploadTranslationFileCommand.add_argument( - "crowdinFilePath", - help="The Crowdin file path", - ) - uploadTranslationFileCommand.add_argument( - "localFilePath", - nargs="?", - default=None, - help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", - ) - uploadSourceFileCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - uploadSourceFileCommand.add_argument( - "-f", - "--localFilePath", - help="The local path to the file.", - ) - exportTranslationsCommand = commands.add_parser( - "exportTranslations", - help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", - ) - exportTranslationsCommand.add_argument( - "-o", - "--output", - help="Directory to save translation files", - required=True, - ) - exportTranslationsCommand.add_argument( - "-l", - "--language", - help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", - default=None, - ) - - args = args.parse_args() - match args.command: - case "xliff2md": - markdownTranslate.generateMarkdown( - xliffPath=args.xliffPath, - outputPath=args.mdPath, - translated=not args.untranslated, - ) - case "uploadSourceFile": - uploadSourceFile(args.localFilePath) - case "getFiles": - getFiles() - case "downloadTranslationFile": - localFilePath = args.localFilePath or args.crowdinFilePath - downloadTranslationFile(args.crowdinFilePath, localFilePath, args.language) - if args.crowdinFilePath.endswith(".xliff"): - preprocessXliff(localFilePath, localFilePath) - elif localFilePath.endswith(".po"): - success, report = checkPo(localFilePath) - if report: - print(report) - if not success: - print(f"\nWarning: Po file {localFilePath} has fatal errors.") - case "checkPo": - poFilePaths = args.poFilePaths - badFilePaths: list[str] = [] - for poFilePath in poFilePaths: - success, report = checkPo(poFilePath) - if report: - print(report) - if not success: - badFilePaths.append(poFilePath) - if badFilePaths: - print(f"\nOne or more po files had fatal errors: {', '.join(badFilePaths)}") - sys.exit(1) - case "uploadTranslationFile": - localFilePath = args.localFilePath or args.crowdinFilePath - needsDelete = False - if args.crowdinFilePath.endswith(".xliff"): - tmp = tempfile.NamedTemporaryFile(suffix=".xliff", delete=False, mode="w") - tmp.close() - shutil.copyfile(localFilePath, tmp.name) - stripXliff(tmp.name, tmp.name, args.old) - localFilePath = tmp.name - needsDelete = True - elif localFilePath.endswith(".po"): - success, report = checkPo(localFilePath) - if report: - print(report) - if not success: - print(f"\nPo file {localFilePath} has errors. Upload aborted.") - sys.exit(1) - uploadTranslationFile(args.crowdinFilePath, localFilePath, args.language) - if needsDelete: - os.remove(localFilePath) - case "exportTranslations": - exportTranslations(args.output, args.language) - case _: - raise ValueError(f"Unknown command {args.command}") - - -if __name__ == "__main__": - main() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py deleted file mode 100644 index fa9a186..0000000 --- a/_l10n/markdownTranslate.py +++ /dev/null @@ -1,737 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024 NV Access Limited. -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -from typing import Generator -import tempfile -import os -import sys -sys.path.insert(0, os.getcwd()) -import contextlib -import lxml.etree -import argparse -import uuid -import re -from itertools import zip_longest -from xml.sax.saxutils import escape as xmlEscape -import difflib -from dataclasses import dataclass -import subprocess - -import buildVars - -RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{buildVars.userAccount}/{buildVars.addon_info["addon_name"]}" -re_kcTitle = re.compile(r"^()$") -re_kcSettingsSection = re.compile(r"^()$") -# Comments that span a single line in their entirety -re_comment = re.compile(r"^$", re.DOTALL) -re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") -re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") -re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") -re_hiddenHeaderRow = re.compile(r"^\|\s*\.\s*\{\.hideHeaderRow\}\s*(\|\s*\.\s*)*\|$") -re_postTableHeaderLine = re.compile(r"^(\|\s*-+\s*)+\|$") -re_tableRow = re.compile(r"^(\|)(.+)(\|)$") -re_translationID = re.compile(r"^(.*)\$\(ID:([0-9a-f-]+)\)(.*)$") - - -def prettyPathString(path: str) -> str: - cwd = os.getcwd() - if os.path.normcase(os.path.splitdrive(path)[0]) != os.path.normcase(os.path.splitdrive(cwd)[0]): - return path - return os.path.relpath(path, cwd) - - -@contextlib.contextmanager -def createAndDeleteTempFilePath_contextManager( - dir: str | None = None, - prefix: str | None = None, - suffix: str | None = None, -) -> Generator[str, None, None]: - """A context manager that creates a temporary file and deletes it when the context is exited""" - with tempfile.NamedTemporaryFile(dir=dir, prefix=prefix, suffix=suffix, delete=False) as tempFile: - tempFilePath = tempFile.name - tempFile.close() - yield tempFilePath - os.remove(tempFilePath) - - -def getLastCommitID(filePath: str) -> str: - # Run the git log command to get the last commit ID for the given file - result = subprocess.run( - ["git", "log", "-n", "1", "--pretty=format:%H", "--", filePath], - capture_output=True, - text=True, - check=True, - ) - commitID = result.stdout.strip() - if not re.match(r"[0-9a-f]{40}", commitID): - raise ValueError(f"Invalid commit ID: '{commitID}' for file '{filePath}'") - return commitID - - -def getGitDir() -> str: - # Run the git rev-parse command to get the root of the git directory - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=True, - ) - gitDir = result.stdout.strip() - if not os.path.isdir(gitDir): - raise ValueError(f"Invalid git directory: '{gitDir}'") - return gitDir - - -def getRawGithubURLForPath(filePath: str) -> str: - gitDirPath = getGitDir() - commitID = getLastCommitID(filePath) - relativePath = os.path.relpath(os.path.abspath(filePath), gitDirPath) - relativePath = relativePath.replace("\\", "/") - return f"{RAW_GITHUB_REPO_URL}/{commitID}/{relativePath}" - - -def skeletonizeLine(mdLine: str) -> str | None: - prefix = "" - suffix = "" - if ( - mdLine.isspace() - or mdLine.strip() == "[TOC]" - or re_hiddenHeaderRow.match(mdLine) - or re_postTableHeaderLine.match(mdLine) - ): - return None - elif m := re_heading.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_bullet.match(mdLine): - prefix, content = m.groups() - elif m := re_number.match(mdLine): - prefix, content = m.groups() - elif m := re_tableRow.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcTitle.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcSettingsSection.match(mdLine): - prefix, content, suffix = m.groups() - elif re_comment.match(mdLine): - return None - ID = str(uuid.uuid4()) - return f"{prefix}$(ID:{ID}){suffix}\n" - - -@dataclass -class Result_generateSkeleton: - numTotalLines: int = 0 - numTranslationPlaceholders: int = 0 - - -def generateSkeleton(mdPath: str, outputPath: str) -> Result_generateSkeleton: - print(f"Generating skeleton file {prettyPathString(outputPath)} from {prettyPathString(mdPath)}...") - res = Result_generateSkeleton() - with ( - open(mdPath, "r", encoding="utf8") as mdFile, - open(outputPath, "w", encoding="utf8", newline="") as outputFile, - ): - for mdLine in mdFile.readlines(): - res.numTotalLines += 1 - skelLine = skeletonizeLine(mdLine) - if skelLine: - res.numTranslationPlaceholders += 1 - else: - skelLine = mdLine - outputFile.write(skelLine) - print( - f"Generated skeleton file with {res.numTotalLines} total lines and {res.numTranslationPlaceholders} translation placeholders", - ) - return res - - -@dataclass -class Result_updateSkeleton: - numAddedLines: int = 0 - numAddedTranslationPlaceholders: int = 0 - numRemovedLines: int = 0 - numRemovedTranslationPlaceholders: int = 0 - numUnchangedLines: int = 0 - numUnchangedTranslationPlaceholders: int = 0 - - -def extractSkeleton(xliffPath: str, outputPath: str): - print(f"Extracting skeleton from {prettyPathString(xliffPath)} to {prettyPathString(outputPath)}...") - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - outputFile.write(skeletonContent) - print(f"Extracted skeleton to {prettyPathString(outputPath)}") - - -def updateSkeleton( - origMdPath: str, - newMdPath: str, - origSkelPath: str, - outputPath: str, -) -> Result_updateSkeleton: - print( - f"Creating updated skeleton file {prettyPathString(outputPath)} from {prettyPathString(origSkelPath)} with changes from {prettyPathString(origMdPath)} to {prettyPathString(newMdPath)}...", - ) - res = Result_updateSkeleton() - with contextlib.ExitStack() as stack: - origMdFile = stack.enter_context(open(origMdPath, "r", encoding="utf8")) - newMdFile = stack.enter_context(open(newMdPath, "r", encoding="utf8")) - origSkelFile = stack.enter_context(open(origSkelPath, "r", encoding="utf8")) - outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) - mdDiff = difflib.ndiff(origMdFile.readlines(), newMdFile.readlines()) - origSkelLines = iter(origSkelFile.readlines()) - for mdDiffLine in mdDiff: - if mdDiffLine.startswith("?"): - continue - if mdDiffLine.startswith(" "): - res.numUnchangedLines += 1 - skelLine = next(origSkelLines) - if re_translationID.match(skelLine): - res.numUnchangedTranslationPlaceholders += 1 - outputFile.write(skelLine) - elif mdDiffLine.startswith("+"): - res.numAddedLines += 1 - skelLine = skeletonizeLine(mdDiffLine[2:]) - if skelLine: - res.numAddedTranslationPlaceholders += 1 - else: - skelLine = mdDiffLine[2:] - outputFile.write(skelLine) - elif mdDiffLine.startswith("-"): - res.numRemovedLines += 1 - origSkelLine = next(origSkelLines) - if re_translationID.match(origSkelLine): - res.numRemovedTranslationPlaceholders += 1 - else: - raise ValueError(f"Unexpected diff line: {mdDiffLine}") - print( - f"Updated skeleton file with {res.numAddedLines} added lines " - f"({res.numAddedTranslationPlaceholders} translation placeholders), " - f"{res.numRemovedLines} removed lines ({res.numRemovedTranslationPlaceholders} translation placeholders), " - f"and {res.numUnchangedLines} unchanged lines ({res.numUnchangedTranslationPlaceholders} translation placeholders)", - ) - return res - - -@dataclass -class Result_generateXliff: - numTranslatableStrings: int = 0 - - -def generateXliff( - mdPath: str, - outputPath: str, - skelPath: str | None = None, -) -> Result_generateXliff: - # If a skeleton file is not provided, first generate one - with contextlib.ExitStack() as stack: - if not skelPath: - skelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=os.path.dirname(outputPath), - prefix=os.path.basename(mdPath), - suffix=".skel", - ), - ) - generateSkeleton(mdPath=mdPath, outputPath=skelPath) - with open(skelPath, "r", encoding="utf8") as skelFile: - skelContent = skelFile.read() - res = Result_generateXliff() - print( - f"Generating xliff file {prettyPathString(outputPath)} from {prettyPathString(mdPath)} and {prettyPathString(skelPath)}...", - ) - with contextlib.ExitStack() as stack: - mdFile = stack.enter_context(open(mdPath, "r", encoding="utf8")) - outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) - fileID = os.path.basename(mdPath) - mdUri = getRawGithubURLForPath(mdPath) - print(f"Including Github raw URL: {mdUri}") - outputFile.write( - '\n' - f'\n' - f'\n', - ) - outputFile.write(f"\n{xmlEscape(skelContent)}\n\n") - res.numTranslatableStrings = 0 - for lineNo, (mdLine, skelLine) in enumerate( - zip_longest(mdFile.readlines(), skelContent.splitlines(keepends=True)), - start=1, - ): - mdLine = mdLine.rstrip() - skelLine = skelLine.rstrip() - if m := re_translationID.match(skelLine): - res.numTranslatableStrings += 1 - prefix, ID, suffix = m.groups() - if prefix and not mdLine.startswith(prefix): - raise ValueError(f'Line {lineNo}: does not start with "{prefix}", {mdLine=}, {skelLine=}') - if suffix and not mdLine.endswith(suffix): - raise ValueError(f'Line {lineNo}: does not end with "{suffix}", {mdLine=}, {skelLine=}') - source = mdLine[len(prefix) : len(mdLine) - len(suffix)] - outputFile.write( - f'\n\nline: {lineNo + 1}\n', - ) - if prefix: - outputFile.write(f'prefix: {xmlEscape(prefix)}\n') - if suffix: - outputFile.write(f'suffix: {xmlEscape(suffix)}\n') - outputFile.write( - "\n" - f"\n" - f"{xmlEscape(source)}\n" - "\n" - "\n", # fmt: skip - ) - else: - if mdLine != skelLine: - raise ValueError(f"Line {lineNo}: {mdLine=} does not match {skelLine=}") - outputFile.write("\n") - print(f"Generated xliff file with {res.numTranslatableStrings} translatable strings") - return res - - -@dataclass -class Result_translateXliff: - numTranslatedStrings: int = 0 - - -def updateXliff( - xliffPath: str, - mdPath: str, - outputPath: str, -): - # uses generateMarkdown, extractSkeleton, updateSkeleton, and generateXliff to generate an updated xliff file. - outputDir = os.path.dirname(outputPath) - print( - f"Generating updated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} and {prettyPathString(mdPath)}...", - ) - with contextlib.ExitStack() as stack: - origMdPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="generated_", suffix=".md"), - ) - generateMarkdown(xliffPath=xliffPath, outputPath=origMdPath, translated=False) - origSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="extracted_", suffix=".skel"), - ) - extractSkeleton(xliffPath=xliffPath, outputPath=origSkelPath) - updatedSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager(dir=outputDir, prefix="updated_", suffix=".skel"), - ) - updateSkeleton( - origMdPath=origMdPath, - newMdPath=mdPath, - origSkelPath=origSkelPath, - outputPath=updatedSkelPath, - ) - generateXliff( - mdPath=mdPath, - skelPath=updatedSkelPath, - outputPath=outputPath, - ) - print(f"Generated updated xliff file {prettyPathString(outputPath)}") - - -def translateXliff( - xliffPath: str, - lang: str, - pretranslatedMdPath: str, - outputPath: str, - allowBadAnchors: bool = False, -) -> Result_translateXliff: - print( - f"Creating {lang} translated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} using {prettyPathString(pretranslatedMdPath)}...", - ) - res = Result_translateXliff() - with contextlib.ExitStack() as stack: - pretranslatedMdFile = stack.enter_context(open(pretranslatedMdPath, "r", encoding="utf8")) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - xliffRoot.set("trgLang", lang) - skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNo, (skelLine, pretranslatedLine) in enumerate( - zip_longest(skeletonContent.splitlines(), pretranslatedMdFile.readlines()), - start=1, - ): - skelLine = skelLine.rstrip() - pretranslatedLine = pretranslatedLine.rstrip() - if m := re_translationID.match(skelLine): - prefix, ID, suffix = m.groups() - if prefix and not pretranslatedLine.startswith(prefix): - raise ValueError( - f'Line {lineNo} of translation does not start with "{prefix}", {pretranslatedLine=}, {skelLine=}', - ) - if suffix and not pretranslatedLine.endswith(suffix): - if allowBadAnchors and (m := re_heading.match(pretranslatedLine)): - print(f"Warning: ignoring bad anchor in line {lineNo}: {pretranslatedLine}") - suffix = m.group(3) - if suffix and not pretranslatedLine.endswith(suffix): - raise ValueError( - f'Line {lineNo} of translation: does not end with "{suffix}", {pretranslatedLine=}, {skelLine=}', - ) - translation = pretranslatedLine[len(prefix) : len(pretranslatedLine) - len(suffix)] - try: - unit = xliffRoot.find(f'./xliff:file/xliff:unit[@id="{ID}"]', namespaces=namespace) - if unit is not None: - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is not None: - target = lxml.etree.Element("target") - target.text = translation - target.tail = "\n" - segment.append(target) - res.numTranslatedStrings += 1 - else: - raise ValueError(f"No segment found for unit {ID}") - else: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - except Exception as e: - e.add_note(f"Line {lineNo}: {pretranslatedLine=}, {skelLine=}") - raise - elif skelLine != pretranslatedLine: - raise ValueError( - f"Line {lineNo}: pretranslated line {pretranslatedLine!r}, does not match skeleton line {skelLine!r}", - ) - xliff.write(outputPath, encoding="utf8", xml_declaration=True) - print(f"Translated xliff file with {res.numTranslatedStrings} translated strings") - return res - - -@dataclass -class Result_generateMarkdown: - numTotalLines = 0 - numTranslatableStrings = 0 - numTranslatedStrings = 0 - numBadTranslationStrings = 0 - - -def generateMarkdown(xliffPath: str, outputPath: str, translated: bool = True) -> Result_generateMarkdown: - print(f"Generating markdown file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)}...") - res = Result_generateMarkdown() - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context(open(outputPath, "w", encoding="utf8", newline="")) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNum, line in enumerate(skeletonContent.splitlines(keepends=True), 1): - res.numTotalLines += 1 - if m := re_translationID.match(line): - prefix, ID, suffix = m.groups() - res.numTranslatableStrings += 1 - unit = xliffRoot.find(f'./xliff:file/xliff:unit[@id="{ID}"]', namespaces=namespace) - if unit is None: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - raise ValueError(f"No segment found for unit {ID}") - source = segment.find("./xliff:source", namespaces=namespace) - if source is None: - raise ValueError(f"No source found for unit {ID}") - translation = "" - if translated: - target = segment.find("./xliff:target", namespaces=namespace) - if target is not None: - targetText = target.text - if targetText: - translation = targetText - # Crowdin treats empty targets () as a literal translation. - # Filter out such strings and count them as bad translations. - if translation in ( - "", - "<target/>", - "", - "<target></target>", - ): - res.numBadTranslationStrings += 1 - translation = "" - else: - res.numTranslatedStrings += 1 - # If we have no translation, use the source text - if not translation: - sourceText = source.text - if sourceText is None: - raise ValueError(f"No source text found for unit {ID}") - translation = sourceText - outputFile.write(f"{prefix}{translation}{suffix}\n") - else: - outputFile.write(line) - print( - f"Generated markdown file with {res.numTotalLines} total lines, {res.numTranslatableStrings} translatable strings, and {res.numTranslatedStrings} translated strings. Ignoring {res.numBadTranslationStrings} bad translated strings", - ) - return res - - -def ensureMarkdownFilesMatch(path1: str, path2: str, allowBadAnchors: bool = False): - print(f"Ensuring files {prettyPathString(path1)} and {prettyPathString(path2)} match...") - with contextlib.ExitStack() as stack: - file1 = stack.enter_context(open(path1, "r", encoding="utf8")) - file2 = stack.enter_context(open(path2, "r", encoding="utf8")) - for lineNo, (line1, line2) in enumerate(zip_longest(file1.readlines(), file2.readlines()), start=1): - line1 = line1.rstrip() - line2 = line2.rstrip() - if line1 != line2: - if ( - re_postTableHeaderLine.match(line1) - and re_postTableHeaderLine.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of post table header line at line {lineNo}: {line1}, {line2}", - ) - continue - if ( - re_hiddenHeaderRow.match(line1) - and re_hiddenHeaderRow.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of hidden header row at line {lineNo}: {line1}, {line2}", - ) - continue - if allowBadAnchors and (m1 := re_heading.match(line1)) and (m2 := re_heading.match(line2)): - print(f"Warning: ignoring bad anchor in headings at line {lineNo}: {line1}, {line2}") - line1 = m1.group(1) + m1.group(2) - line2 = m2.group(1) + m2.group(2) - if line1 != line2: - raise ValueError(f"Files do not match at line {lineNo}: {line1=} {line2=}") - print("Files match") - - -def markdownTranslateCommand(command: str, *args): - print(f"Running markdownTranslate command: {command} {' '.join(args)}") - subprocess.run(["python", __file__, command, *args], check=True) - - -def pretranslateAllPossibleLanguages(langsDir: str, mdBaseName: str): - # This function walks through all language directories in the given directory, skipping en (English) and translates the English xlif and skel file along with the lang's pretranslated md file - enXliffPath = os.path.join(langsDir, "en", f"{mdBaseName}.xliff") - if not os.path.exists(enXliffPath): - raise ValueError(f"English xliff file {enXliffPath} does not exist") - allLangs = set() - succeededLangs = set() - skippedLangs = set() - for langDir in os.listdir(langsDir): - if langDir == "en": - continue - langDirPath = os.path.join(langsDir, langDir) - if not os.path.isdir(langDirPath): - continue - langPretranslatedMdPath = os.path.join(langDirPath, f"{mdBaseName}.md") - if not os.path.exists(langPretranslatedMdPath): - continue - allLangs.add(langDir) - langXliffPath = os.path.join(langDirPath, f"{mdBaseName}.xliff") - if os.path.exists(langXliffPath): - print(f"Skipping {langDir} as the xliff file already exists") - skippedLangs.add(langDir) - continue - try: - translateXliff( - xliffPath=enXliffPath, - lang=langDir, - pretranslatedMdPath=langPretranslatedMdPath, - outputPath=langXliffPath, - allowBadAnchors=True, - ) - except Exception as e: - print(f"Failed to translate {langDir}: {e}") - continue - rebuiltLangMdPath = os.path.join(langDirPath, f"rebuilt_{mdBaseName}.md") - try: - generateMarkdown( - xliffPath=langXliffPath, - outputPath=rebuiltLangMdPath, - ) - except Exception as e: - print(f"Failed to rebuild {langDir} markdown: {e}") - os.remove(langXliffPath) - continue - try: - ensureMarkdownFilesMatch(rebuiltLangMdPath, langPretranslatedMdPath, allowBadAnchors=True) - except Exception as e: - print(f"Rebuilt {langDir} markdown does not match pretranslated markdown: {e}") - os.remove(langXliffPath) - continue - os.remove(rebuiltLangMdPath) - print(f"Successfully pretranslated {langDir}") - succeededLangs.add(langDir) - if len(skippedLangs) > 0: - print(f"Skipped {len(skippedLangs)} languages already pretranslated.") - print(f"Pretranslated {len(succeededLangs)} out of {len(allLangs) - len(skippedLangs)} languages.") - - -if __name__ == "__main__": - mainParser = argparse.ArgumentParser() - commandParser = mainParser.add_subparsers(title="commands", dest="command", required=True) - generateXliffParser = commandParser.add_parser("generateXliff") - generateXliffParser.add_argument( - "-m", - "--markdown", - dest="md", - type=str, - required=True, - help="The markdown file to generate the xliff file for", - ) - generateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the xliff file to", - ) - updateXliffParser = commandParser.add_parser("updateXliff") - updateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The original xliff file", - ) - updateXliffParser.add_argument( - "-m", - "--newMarkdown", - dest="md", - type=str, - required=True, - help="The new markdown file", - ) - updateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the updated xliff to", - ) - translateXliffParser = commandParser.add_parser("translateXliff") - translateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to translate", - ) - translateXliffParser.add_argument( - "-l", - "--lang", - dest="lang", - type=str, - required=True, - help="The language to translate to", - ) - translateXliffParser.add_argument( - "-p", - "--pretranslatedMarkdown", - dest="pretranslatedMd", - type=str, - required=True, - help="The pretranslated markdown file to use", - ) - translateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the translated xliff file to", - ) - generateMarkdownParser = commandParser.add_parser("generateMarkdown") - generateMarkdownParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to generate the markdown file for", - ) - generateMarkdownParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the markdown file to", - ) - generateMarkdownParser.add_argument( - "-u", - "--untranslated", - dest="translated", - action="store_false", - help="Generate the markdown file with the untranslated strings", - ) - ensureMarkdownFilesMatchParser = commandParser.add_parser("ensureMarkdownFilesMatch") - ensureMarkdownFilesMatchParser.add_argument( - dest="path1", - type=str, - help="The first markdown file", - ) - ensureMarkdownFilesMatchParser.add_argument( - dest="path2", - type=str, - help="The second markdown file", - ) - pretranslateLangsParser = commandParser.add_parser("pretranslateLangs") - pretranslateLangsParser.add_argument( - "-d", - "--langs-dir", - dest="langsDir", - type=str, - required=True, - help="The directory containing the language directories", - ) - pretranslateLangsParser.add_argument( - "-b", - "--md-base-name", - dest="mdBaseName", - type=str, - required=True, - help="The base name of the markdown files to pretranslate", - ) - args = mainParser.parse_args() - match args.command: - case "generateXliff": - generateXliff(mdPath=args.md, outputPath=args.output) - case "updateXliff": - updateXliff( - xliffPath=args.xliff, - mdPath=args.md, - outputPath=args.output, - ) - case "generateMarkdown": - generateMarkdown(xliffPath=args.xliff, outputPath=args.output, translated=args.translated) - case "translateXliff": - translateXliff( - xliffPath=args.xliff, - lang=args.lang, - pretranslatedMdPath=args.pretranslatedMd, - outputPath=args.output, - ) - case "pretranslateLangs": - pretranslateAllPossibleLanguages(langsDir=args.langsDir, mdBaseName=args.mdBaseName) - case "ensureMarkdownFilesMatch": - ensureMarkdownFilesMatch(path1=args.path1, path2=args.path2) - case _: - raise ValueError(f"Unknown command: {args.command}") diff --git a/_l10n/md2html.py b/_l10n/md2html.py deleted file mode 100644 index 01acab0..0000000 --- a/_l10n/md2html.py +++ /dev/null @@ -1,197 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023-2024 NV Access Limited -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - -import argparse -from copy import deepcopy -import io -import re -import shutil - -DEFAULT_EXTENSIONS = frozenset( - { - # Supports tables, HTML mixed with markdown, code blocks, custom attributes and more - "markdown.extensions.extra", - # Allows TOC with [TOC]" - "markdown.extensions.toc", - # Makes list behaviour better, including 2 space indents by default - "mdx_truly_sane_lists", - # External links will open in a new tab, and title will be set to the link text - "markdown_link_attr_modifier", - # Adds links to GitHub authors, issues and PRs - "mdx_gh_links", - }, -) - -EXTENSIONS_CONFIG = { - "markdown_link_attr_modifier": { - "new_tab": "external_only", - "auto_title": "on", - }, - "mdx_gh_links": { - "user": "nvaccess", - "repo": "nvda", - }, -} - -RTL_LANG_CODES = frozenset({"ar", "fa", "he"}) - -HTML_HEADERS = """ - - - - -{title} - - - -{extraStylesheet} - - -""".strip() - - -def _getTitle(mdBuffer: io.StringIO, isKeyCommands: bool = False) -> str: - if isKeyCommands: - TITLE_RE = re.compile(r"^$") - # Make next read at start of buffer - mdBuffer.seek(0) - for line in mdBuffer.readlines(): - match = TITLE_RE.match(line.strip()) - if match: - return match.group(1) - - raise ValueError("No KC:title command found in userGuide.md") - - else: - # Make next read at start of buffer - mdBuffer.seek(0) - # Remove heading hashes and trailing whitespace to get the tab title - title = mdBuffer.readline().strip().lstrip("# ") - - return title - - -def _createAttributeFilter() -> dict[str, set[str]]: - # Create attribute filter exceptions for HTML sanitization - import nh3 - - allowedAttributes: dict[str, set[str]] = deepcopy(nh3.ALLOWED_ATTRIBUTES) - - attributesWithAnchors = {"h1", "h2", "h3", "h4", "h5", "h6", "td"} - attributesWithClass = {"div", "span", "a", "th", "td"} - - # Allow IDs for anchors - for attr in attributesWithAnchors: - if attr not in allowedAttributes: - allowedAttributes[attr] = set() - allowedAttributes[attr].add("id") - - # Allow class for styling - for attr in attributesWithClass: - if attr not in allowedAttributes: - allowedAttributes[attr] = set() - allowedAttributes[attr].add("class") - - # link rel and target is set by markdown_link_attr_modifier - allowedAttributes["a"].update({"rel", "target"}) - - return allowedAttributes - - -ALLOWED_ATTRIBUTES = _createAttributeFilter() - - -def _generateSanitizedHTML(md: str, isKeyCommands: bool = False) -> str: - import markdown - import nh3 - - extensions = set(DEFAULT_EXTENSIONS) - if isKeyCommands: - from keyCommandsDoc import KeyCommandsExtension - - extensions.add(KeyCommandsExtension()) - - htmlOutput = markdown.markdown( - text=md, - extensions=extensions, - extension_configs=EXTENSIONS_CONFIG, - ) - - # Sanitize html output from markdown to prevent XSS from translators - htmlOutput = nh3.clean( - htmlOutput, - attributes=ALLOWED_ATTRIBUTES, - # link rel is handled by markdown_link_attr_modifier - link_rel=None, - # Keep key command comments and similar - strip_comments=False, - ) - - return htmlOutput - - -def main(source: str, dest: str, lang: str = "en", docType: str | None = None): - print(f"Converting {docType or 'document'} ({lang=}) at {source} to {dest}") - isUserGuide = docType == "userGuide" - isDevGuide = docType == "developerGuide" - isChanges = docType == "changes" - isKeyCommands = docType == "keyCommands" - if docType and not any([isUserGuide, isDevGuide, isChanges, isKeyCommands]): - raise ValueError(f"Unknown docType {docType}") - with open(source, "r", encoding="utf-8") as mdFile: - mdStr = mdFile.read() - - with io.StringIO() as mdBuffer: - mdBuffer.write(mdStr) - title = _getTitle(mdBuffer, isKeyCommands) - - if isUserGuide or isDevGuide: - extraStylesheet = '' - elif isChanges or isKeyCommands: - extraStylesheet = "" - else: - raise ValueError(f"Unknown target type for {dest}") - - htmlBuffer = io.StringIO() - htmlBuffer.write( - HTML_HEADERS.format( - lang=lang, - dir="rtl" if lang in RTL_LANG_CODES else "ltr", - title=title, - extraStylesheet=extraStylesheet, - ), - ) - - htmlOutput = _generateSanitizedHTML(mdStr, isKeyCommands) - # Make next write append at end of buffer - htmlBuffer.seek(0, io.SEEK_END) - htmlBuffer.write(htmlOutput) - - # Make next write append at end of buffer - htmlBuffer.seek(0, io.SEEK_END) - htmlBuffer.write("\n\n\n") - - with open(dest, "w", encoding="utf-8") as targetFile: - # Make next read at start of buffer - htmlBuffer.seek(0) - shutil.copyfileobj(htmlBuffer, targetFile) - - htmlBuffer.close() - - -if __name__ == "__main__": - args = argparse.ArgumentParser() - args.add_argument("-l", "--lang", help="Language code", action="store", default="en") - args.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - args.add_argument("source", help="Path to the markdown file") - args.add_argument("dest", help="Path to the resulting html file") - args = args.parse_args() - main(source=args.source, dest=args.dest, lang=args.lang, docType=args.docType) From 0505a3b4ccb7d72fca8f1341839d39b99c35a8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:38:01 +0100 Subject: [PATCH 043/142] Don't run pre-commit since it requires a different token to access hooks --- .github/workflows/exportAddonToCrowdin.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 7ca8edb..c16481a 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -29,10 +29,6 @@ jobs: sudo apt install gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Run pre-commit - run: | - # Ensure uv environment is up to date. - uv run pre-commit run uv-lock --all-files - name: Build add-on and pot file run: | uv run scons From fd2554b8a0af52fda7af32e5538dcab220a60c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:24:10 +0100 Subject: [PATCH 044/142] Merge translations into branch --- .github/workflows/exportAddonToCrowdin.yml | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index c16481a..04067a0 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -9,6 +9,7 @@ concurrency: env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + downloadTranslationsBranch: l10n jobs: build: runs-on: ubuntu-latest @@ -67,3 +68,36 @@ jobs: git commit -m "Update Crowdin file ids and hashes" git push fi + - name: Download translations from Crowdin + run: | + uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n + mkdir -p addon/locale + mkdir -p addon/doc + for dir in _addonL10n/${{ steps.getAddonInfo.outputs.addonId }}/*; do + echo "Processing: $dir" + if [ -d "$dir" ]; then + langCode=$(basename "$dir") + poFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.po" + if [ -f "$poFile" ]; then + mkdir -p "addon/locale/$langCode/LC_MESSAGES" + echo "Moving $poFile to addon/locale/$langCode/LC_MESSAGES/nvda.po" + mv "$poFile" "addon/locale/$langCode/LC_MESSAGES/nvda.po" + fi + mdFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.md" + if [ -f "$mdFile" ]; then + mkdir -p "addon/doc/$langCode" + echo "Moving $mdFile to addon/doc/$langCode/readme.md" + mv "$mdFile" "addon/doc/$langCode/readme.md" + fi + else + echo "Skipping invalid directory: $dir" + fi + done + git add addon/locale addon/doc + if git diff --staged --quiet; then + echo "Nothing added to commit." + else + git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" + git checkout -b ${{ env.downloadTranslationsBranch }} + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + fi From b30f46fb414f5848b95cb755bf1e6c13069284eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:38:52 +0100 Subject: [PATCH 045/142] Add project id without using vars --- .github/workflows/exportAddonToCrowdin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 04067a0..7ba4dca 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -7,7 +7,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: From 70293c84189a5e9f51e459defea26964cee06b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:42:24 +0100 Subject: [PATCH 046/142] Schedule workflow --- .github/workflows/exportAddonToCrowdin.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 7ba4dca..eea43a7 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -3,6 +3,9 @@ name: Export add-on to Crowdin on: workflow_dispatch: + schedule: + # Every Monday at 00:00 UTC + - cron: '0 0 * * 1' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From da09c8c445285ea419dbab16a70e755f6087748c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:43:26 +0100 Subject: [PATCH 047/142] Rename workflow --- .github/workflows/{exportAddonToCrowdin.yml => crowdinL10n.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{exportAddonToCrowdin.yml => crowdinL10n.yml} (99%) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/crowdinL10n.yml similarity index 99% rename from .github/workflows/exportAddonToCrowdin.yml rename to .github/workflows/crowdinL10n.yml index eea43a7..46f6271 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/crowdinL10n.yml @@ -1,4 +1,4 @@ -name: Export add-on to Crowdin +name: Crowdin l10n on: From 5c52f33da968846e9f5fcf16faa37f33455bd316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:56:48 +0100 Subject: [PATCH 048/142] Create PR --- .github/workflows/crowdinL10n.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 46f6271..d3f507c 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -103,4 +103,7 @@ jobs: git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + gh pr create --base ${{ env.GITHUB_DEFAULT_BRANCH }} --head ${{ env.downloadTranslationsBranch }} + --title "Update tracked translations from Crowdin" \ + --body "This pull request updates translations to languages being tracked from Crowdin." fi From d0d5e0393aaa0fb8d5394113169c8acd59cc6228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 22:04:39 +0100 Subject: [PATCH 049/142] =?UTF-8?q?Don't=20create=20a=20PR=20since=20this?= =?UTF-8?q?=20n=C2=A1may=20need=20a=20personal=20access=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/crowdinL10n.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index d3f507c..e7a1460 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -13,11 +13,13 @@ env: crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n + GH_TOKEN: ${{ github.token }} jobs: build: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -103,7 +105,5 @@ jobs: git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - gh pr create --base ${{ env.GITHUB_DEFAULT_BRANCH }} --head ${{ env.downloadTranslationsBranch }} - --title "Update tracked translations from Crowdin" \ - --body "This pull request updates translations to languages being tracked from Crowdin." fi + From b40f94ac87d8fe1198b2ad8a8909f312c20ee613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 22:05:30 +0100 Subject: [PATCH 050/142] Update removing permissions for PR --- .github/workflows/crowdinL10n.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index e7a1460..cbd0df9 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -13,13 +13,11 @@ env: crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n - GH_TOKEN: ${{ github.token }} jobs: build: runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - name: Checkout add-on uses: actions/checkout@v6 From cb7807e096d093b47c220d5559d151315e7449f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 06:52:33 +0100 Subject: [PATCH 051/142] Update Python version compatible with ubuntu-latest --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 24ee5b1..2c45fe3 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.13.11 From 1449a01c80a4395536f2c745aea860b03330beeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 07:01:38 +0100 Subject: [PATCH 052/142] Add dry-run --- .github/workflows/crowdinL10n.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index cbd0df9..1b966b0 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,6 +3,12 @@ name: Crowdin l10n on: workflow_dispatch: + inputs: + dry-run: + description: 'Dry run mode (skip Crowdin upload/download)' + required: false + type: boolean + default: false schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' @@ -41,21 +47,21 @@ jobs: id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' && inputs.dry-run != true }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' && inputs.dry-run != true }} run: | uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' && inputs.dry-run != true }} run: | uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json @@ -72,6 +78,7 @@ jobs: git push fi - name: Download translations from Crowdin + if: ${{ inputs.dry-run != true }} run: | uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n mkdir -p addon/locale @@ -104,4 +111,3 @@ jobs: git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} fi - From 697d04857511423e90932d1a854f2b735da0fe13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 09:37:10 +0100 Subject: [PATCH 053/142] Optimize workflow to test with act and docker locally --- .github/workflows/crowdinL10n.yml | 13 ++++++---- .gitignore | 3 +++ sha256.py | 41 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 sha256.py diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 1b966b0..5f75489 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -35,17 +35,19 @@ jobs: python-version-file: ".python-version" - name: Install gettext run: | - sudo apt update - sudo apt install gettext + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Install dependencies + run: uv pip install --system scons markdown - name: Build add-on and pot file run: | - uv run scons - uv run scons pot + uv run --with scons --with markdown scons + uv run --with scons --with markdown scons pot - name: Get add-on info id: getAddonInfo - run: uv run ./.github/workflows/setOutputs.py + run: uv run --with scons --with markdown ./.github/workflows/setOutputs.py - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} run: | @@ -65,6 +67,7 @@ jobs: run: | uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json + if: ${{ inputs.dry-run != true }} id: commit run: | git config --local user.name github-actions diff --git a/.gitignore b/.gitignore index 1750f2c..e915e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ addon/locale/*/*.ini *.pyc *.nvda-addon .sconsign.dblite + +# act configuration +.actrc diff --git a/sha256.py b/sha256.py new file mode 100644 index 0000000..51c903b --- /dev/null +++ b/sha256.py @@ -0,0 +1,41 @@ +# Copyright (C) 2020-2025 NV Access Limited, Noelia Ruiz Martínez +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +import argparse +import hashlib +import typing + +#: The read size for each chunk read from the file, prevents memory overuse with large files. +BLOCK_SIZE = 65536 + + +def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = BLOCK_SIZE): + """ + :param binaryReadModeFiles: A list of files (mode=='rb'). Calculate its sha256 hash. + :param blockSize: The size of each read. + :return: The Sha256 hex digest. + """ + sha256 = hashlib.sha256() + for f in binaryReadModeFiles: + with open(f, "rb") as file: + assert file.readable() and file.mode == "rb" + for block in iter(lambda: file.read(blockSize), b""): + sha256.update(block) + return sha256.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + type=argparse.FileType("rb"), + dest="file", + help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", + ) + args = parser.parse_args() + checksum = sha256_checksum(args.file) + print(f"Sha256:\t {checksum}") + + +if __name__ == "__main__": + main() From 8c9247b1f547386c4badf9c43609341c45f2054b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 30 Dec 2025 05:42:28 +0100 Subject: [PATCH 054/142] Update uv.lock --- uv.lock | 463 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 462 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index bda0207..e0def29 100644 --- a/uv.lock +++ b/uv.lock @@ -1,3 +1,464 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "requests" }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, + { name = "lxml", specifier = "==6.0.2" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "ruff", specifier = "==0.14.5" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.9.11" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/35/d806c2ca66072e36dc340ccdbeb2af7e4f1b5bcc33f1481f00ceed476708/nodejs_wheel_binaries-24.12.0.tar.gz", hash = "sha256:f1b50aa25375e264697dec04b232474906b997c2630c8f499f4caf3692938435", size = 8058, upload-time = "2025-12-11T21:12:26.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/3b/9d6f044319cd5b1e98f07c41e2465b58cadc1c9c04a74c891578f3be6cb5/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:7564ddea0a87eff34e9b3ef71764cc2a476a8f09a5cccfddc4691148b0a47338", size = 55125859, upload-time = "2025-12-11T21:11:58.132Z" }, + { url = "https://files.pythonhosted.org/packages/48/a5/f5722bf15c014e2f476d7c76bce3d55c341d19122d8a5d86454db32a61a4/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:8ff929c4669e64613ceb07f5bbd758d528c3563820c75d5de3249eb452c0c0ab", size = 55309035, upload-time = "2025-12-11T21:12:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/a9/61/68d39a6f1b5df67805969fd2829ba7e80696c9af19537856ec912050a2be/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6ebacefa8891bc456ad3655e6bce0af7e20ba08662f79d9109986faeb703fd6f", size = 59661017, upload-time = "2025-12-11T21:12:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/16/a1/31aad16f55a5e44ca7ea62d1367fc69f4b6e1dba67f58a0a41d0ed854540/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3292649a03682ccbfa47f7b04d3e4240e8c46ef04dc941b708f20e4e6a764f75", size = 60159770, upload-time = "2025-12-11T21:12:08.696Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5e/b7c569aa1862690ca4d4daf3a64cafa1ea6ce667a9e3ae3918c56e127d9b/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7fb83df312955ea355ba7f8cbd7055c477249a131d3cb43b60e4aeb8f8c730b1", size = 61653561, upload-time = "2025-12-11T21:12:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/567f58d7ba69ff0208be849b37be0f2c2e99c69e49334edd45ff44f00043/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2473c819448fedd7b036dde236b09f3c8bbf39fbbd0c1068790a0498800f498b", size = 62238331, upload-time = "2025-12-11T21:12:16.143Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9d/c6492188ce8de90093c6755a4a63bb6b2b4efb17094cb4f9a9a49c73ed3b/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:2090d59f75a68079fabc9b86b14df8238b9aecb9577966dc142ce2a23a32e9bb", size = 41342076, upload-time = "2025-12-11T21:12:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uv" +version = "0.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/08/3bf76403ea7c22feef634849137fab10b28ab5ba5bbf08a53390763d5448/uv-0.9.11.tar.gz", hash = "sha256:605a7a57f508aabd029fc0c5ef5c60a556f8c50d32e194f1a300a9f4e87f18d4", size = 3744387, upload-time = "2025-11-20T23:20:00.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/26/8f917e9faddd9cb49abcbc8c7dac5343b0f61d04c6ac36873d2a324fee1a/uv-0.9.11-py3-none-linux_armv6l.whl", hash = "sha256:803f85cf25ab7f1fca10fe2e40a1b9f5b1d48efc25efd6651ba3c9668db6a19e", size = 20787588, upload-time = "2025-11-20T23:18:53.738Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1f/eafd39c719ddee19fc25884f68c1a7e736c0fca63c1cbef925caf8ebd739/uv-0.9.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6a31b0bd4eaec59bf97816aefbcd75cae4fcc8875c4b19ef1846b7bff3d67c70", size = 19922144, upload-time = "2025-11-20T23:18:57.569Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/6b9fac39e5b65fa47dba872dcf171f1470490cd645343e8334f20f73885b/uv-0.9.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48548a23fb5a103b8955dfafff7d79d21112b8e25ce5ff25e3468dc541b20e83", size = 18380643, upload-time = "2025-11-20T23:19:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9a/d4080e95950a4fc6fdf20d67b9a43ffb8e3d6d6b7c8dda460ae73ddbecd9/uv-0.9.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cb680948e678590b5960744af2ecea6f2c0307dbb74ac44daf5c00e84ad8c09f", size = 20310262, upload-time = "2025-11-20T23:19:04.914Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/86d9c881bd6accf2b766f7193b50e9d5815f2b34806191d90ea24967965e/uv-0.9.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ef1982295e5aaf909a9668d6fb6abfc5089666c699f585a36f3a67f1a22916a", size = 20392988, upload-time = "2025-11-20T23:19:08.258Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/6a227b7ca1829442c1419ba1db856d176b6e0861f9bf9355a8790a5d02b5/uv-0.9.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92ff773aa4193148019533c55382c2f9c661824bbf0c2e03f12aeefc800ede57", size = 21394892, upload-time = "2025-11-20T23:19:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/df45b8409923121de8c4081c9d6d8ba3273eaa450645e1e542d83179c7b5/uv-0.9.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70137a46675bbecf3a8b43d292a61767f1b944156af3d0f8d5986292bd86f6cf", size = 22987735, upload-time = "2025-11-20T23:19:16.27Z" }, + { url = "https://files.pythonhosted.org/packages/89/51/bbf3248a619c9f502d310a11362da5ed72c312d354fb8f9667c5aa3be9dd/uv-0.9.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5af9117bab6c4b3a1cacb0cddfb3cd540d0adfb13c7b8a9a318873cf2d07e52", size = 22617321, upload-time = "2025-11-20T23:19:20.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cd/a158ec989c5433dc86ebd9fea800f2aed24255b84ab65b6d7407251e5e31/uv-0.9.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8cc86940d9b3a425575f25dc45247be2fb31f7fed7bf3394ae9daadd466e5b80", size = 21615712, upload-time = "2025-11-20T23:19:23.71Z" }, + { url = "https://files.pythonhosted.org/packages/73/da/2597becbc0fcbb59608d38fda5db79969e76dedf5b072f0e8564c8f0628b/uv-0.9.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97906ca1b90dac91c23af20e282e2e37c8eb80c3721898733928a295f2defda", size = 21661022, upload-time = "2025-11-20T23:19:27.385Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/9b8f3b3529b23c2a6f5b9612da70ea53117935ec999757b4f1d640f63d63/uv-0.9.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d901269e1db72abc974ba61d37be6e56532e104922329e0b553d9df07ba224be", size = 20440548, upload-time = "2025-11-20T23:19:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/683afdb83e96dd966eb7cf3688af56a1b826c8bc1e8182fb10ec35b3e391/uv-0.9.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8abfb7d4b136de3e92dd239ea9a51d4b7bbb970dc1b33bec84d08facf82b9a6e", size = 21493758, upload-time = "2025-11-20T23:19:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/f4/00/99848bc9834aab104fa74aa1a60b1ca478dee824d2e4aacb15af85673572/uv-0.9.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:1f8afc13b3b94bce1e72514c598d41623387b2b61b68d7dbce9a01a0d8874860", size = 20332324, upload-time = "2025-11-20T23:19:38.376Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/8cfd1bb1cc5d768cb334f976ba2686c6327e4ac91c16b8469b284956d4d9/uv-0.9.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7d414cfa410f1850a244d87255f98d06ca61cc13d82f6413c4f03e9e0c9effc7", size = 20845062, upload-time = "2025-11-20T23:19:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/a0/42/43f66bfc621464dabe9cfe3cbf69cddc36464da56ab786c94fc9ccf99cc7/uv-0.9.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:edc14143d0ba086a7da4b737a77746bb36bc00e3d26466f180ea99e3bf795171", size = 21857559, upload-time = "2025-11-20T23:19:46.026Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/bfd41bf087522601c724d712c3727aeb62f51b1f67c4ab86a078c3947525/uv-0.9.11-py3-none-win32.whl", hash = "sha256:af5fd91eecaa04b4799f553c726307200f45da844d5c7c5880d64db4debdd5dc", size = 19639246, upload-time = "2025-11-20T23:19:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2f/d51c02627de68a7ca5b82f0a5d61d753beee3fe696366d1a1c5d5e40cd58/uv-0.9.11-py3-none-win_amd64.whl", hash = "sha256:c65a024ad98547e32168f3a52360fe73ff39cd609a8fb9dd2509aac91483cfc8", size = 21626822, upload-time = "2025-11-20T23:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/af/d8/e07e866ee328d3c9f27a6d57a018d8330f47be95ef4654a178779c968a66/uv-0.9.11-py3-none-win_arm64.whl", hash = "sha256:4907a696c745703542ed2559bdf5380b92c8b1d4bf290ebfed45bf9a2a2c6690", size = 20046856, upload-time = "2025-11-20T23:19:58.517Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] From 6089057b2feba44cd46a5d3433590940932228ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 25 Mar 2026 06:39:04 +0100 Subject: [PATCH 055/142] Add workflow call to build the add-on, so it can be reused in other workflows --- .github/workflows/build_addon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index 9e0fa64..0ed7a64 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -10,6 +10,7 @@ on: branches: [ main, master ] workflow_dispatch: + workflow_call: jobs: build: From d8154a9dd511ba9661b7dc781c364dea57cd491c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 25 Mar 2026 16:39:20 +0100 Subject: [PATCH 056/142] Update workflow for testing --- .github/workflows/crowdinL10n.yml | 120 +++++++++++++----------------- 1 file changed, 50 insertions(+), 70 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 5f75489..a875a6a 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -16,12 +16,14 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: build: - runs-on: ubuntu-latest + uses: ./.github/workflows/build_addon.yml + crowdinSync: + needs: build + runs-on: windows-latest permissions: contents: write steps: @@ -33,84 +35,62 @@ jobs: uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install gettext - run: | - sudo apt-get update -qq - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Install dependencies - run: uv pip install --system scons markdown - - name: Build add-on and pot file - run: | - uv run --with scons --with markdown scons - uv run --with scons --with markdown scons pot - name: Get add-on info id: getAddonInfo - run: uv run --with scons --with markdown ./.github/workflows/setOutputs.py - - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} - run: | - mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - - name: update md - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' && inputs.dry-run != true }} + shell: pwsh run: | - mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' && inputs.dry-run != true }} + uv sync + uv run ./.github/workflows/setOutputs.py + - name: Download l10nUtil from nvdal10n + if: ${{ inputs.dry-run != true }} run: | - uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Update pot - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' && inputs.dry-run != true }} + gh run download --repo nvaccess/nvdal10n --name l10nUtil --dir _l10n + - name: Upload md + if: ${{ (steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdateMd == 'true') && inputs.dry-run != true }} run: | - uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json + Move-Item -Path readme.md -Destination "${{ steps.getAddonInfo.outputs.addonId }}.md" + uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md -c addon + - name: Download pot from build job if: ${{ inputs.dry-run != true }} - id: commit + uses: actions/download-artifact@v8 + with: + name: packaged_addon + - name: Upload pot + if: ${{ (steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdatePot == 'true') && inputs.dry-run != true }} run: | - git config --local user.name github-actions - git config --local user.email github-actions@github.com - git status - git add *.json - if git diff --staged --quiet; then - echo "Nothing added to commit." - else - git commit -m "Update Crowdin file ids and hashes" - git push - fi + uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot -c addon - name: Download translations from Crowdin if: ${{ inputs.dry-run != true }} run: | - uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n - mkdir -p addon/locale - mkdir -p addon/doc - for dir in _addonL10n/${{ steps.getAddonInfo.outputs.addonId }}/*; do - echo "Processing: $dir" - if [ -d "$dir" ]; then - langCode=$(basename "$dir") - poFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.po" - if [ -f "$poFile" ]; then - mkdir -p "addon/locale/$langCode/LC_MESSAGES" - echo "Moving $poFile to addon/locale/$langCode/LC_MESSAGES/nvda.po" - mv "$poFile" "addon/locale/$langCode/LC_MESSAGES/nvda.po" - fi - mdFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.md" - if [ -f "$mdFile" ]; then - mkdir -p "addon/doc/$langCode" - echo "Moving $mdFile to addon/doc/$langCode/readme.md" - mv "$mdFile" "addon/doc/$langCode/readme.md" - fi - else - echo "Skipping invalid directory: $dir" - fi - done + uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon + New-Item -ItemType Directory -Force -Path addon/locale | Out-Null + New-Item -ItemType Directory -Force -Path addon/doc | Out-Null + foreach ($dir in Get-ChildItem -Path "_addonL10n/${{ steps.getAddonInfo.outputs.addonId }}" -Directory) { + Write-Host "Processing: $($dir.FullName)" + $langCode = $dir.Name + $poFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.po" + if (Test-Path -PathType Leaf $poFile) { + $targetDir = "addon/locale/$langCode/LC_MESSAGES" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Write-Host "Moving $poFile to $targetDir/nvda.po" + Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + } + $mdFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.md" + if (Test-Path -PathType Leaf $mdFile) { + $targetDir = "addon/doc/$langCode" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Write-Host "Moving $mdFile to $targetDir/readme.md" + Move-Item -Path $mdFile -Destination "$targetDir/readme.md" -Force + } + } git add addon/locale addon/doc - if git diff --staged --quiet; then - echo "Nothing added to commit." - else - git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" - git checkout -b ${{ env.downloadTranslationsBranch }} - git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - fi + $diff = git diff --staged --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host "Nothing added to commit." + } else { + git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" + git checkout -b ${{ env.downloadTranslationsBranch }} + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + } From e699abc415a5e65ef0ac4eeaf2655efa1d773bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 18 Apr 2026 07:04:49 +0200 Subject: [PATCH 057/142] Improve workflow to download files from Crowdin --- .github/workflows/crowdinL10n.yml | 37 +++++-------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a875a6a..a9e23db 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,12 +3,6 @@ name: Crowdin l10n on: workflow_dispatch: - inputs: - dry-run: - description: 'Dry run mode (skip Crowdin upload/download)' - required: false - type: boolean - default: false schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' @@ -19,10 +13,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: - build: - uses: ./.github/workflows/build_addon.yml crowdinSync: - needs: build runs-on: windows-latest permissions: contents: write @@ -44,25 +35,10 @@ jobs: uv sync uv run ./.github/workflows/setOutputs.py - name: Download l10nUtil from nvdal10n - if: ${{ inputs.dry-run != true }} run: | - gh run download --repo nvaccess/nvdal10n --name l10nUtil --dir _l10n - - name: Upload md - if: ${{ (steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdateMd == 'true') && inputs.dry-run != true }} - run: | - Move-Item -Path readme.md -Destination "${{ steps.getAddonInfo.outputs.addonId }}.md" - uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md -c addon - - name: Download pot from build job - if: ${{ inputs.dry-run != true }} - uses: actions/download-artifact@v8 - with: - name: packaged_addon - - name: Upload pot - if: ${{ (steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdatePot == 'true') && inputs.dry-run != true }} - run: | - uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot -c addon + # Download the latest release asset matching the pattern from the specified repository + gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - name: Download translations from Crowdin - if: ${{ inputs.dry-run != true }} run: | uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon New-Item -ItemType Directory -Force -Path addon/locale | Out-Null @@ -77,12 +53,11 @@ jobs: Write-Host "Moving $poFile to $targetDir/nvda.po" Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force } - $mdFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.md" - if (Test-Path -PathType Leaf $mdFile) { + $xliffFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.xliff" + if (Test-Path -PathType Leaf $xliffFile) { $targetDir = "addon/doc/$langCode" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null - Write-Host "Moving $mdFile to $targetDir/readme.md" - Move-Item -Path $mdFile -Destination "$targetDir/readme.md" -Force + Write-Host "Moving $xliffFile to $targetDir/readme.md" + uv run l10nUtil.exe xliff2md $xliffFile "$targetDir/readme.md" } } git add addon/locale addon/doc From 5eafa641d1bb7a75563842f11f850122c41a6351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 18 Apr 2026 07:09:08 +0200 Subject: [PATCH 058/142] Update setOutputs to set just add-on id to download translations --- .github/workflows/setOutputs.py | 40 +-------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index d8cce3b..a53aeb0 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -4,55 +4,17 @@ import os import sys -import json sys.path.insert(0, os.getcwd()) import buildVars -import sha256 def main(): addonId = buildVars.addon_info["addon_name"] - readmeFile = os.path.join(os.getcwd(), "readme.md") - i18nSources = sorted(buildVars.i18nSources) - readmeSha = None - i18nSourcesSha = None - shouldUpdateMd = False - shouldUpdatePot = False - shouldAddMdFromScratch = False - shouldAddPotFromScratch = False - if os.path.isfile(readmeFile): - readmeSha = sha256.sha256_checksum([readmeFile]) - i18nSourcesSha = sha256.sha256_checksum(i18nSources) - hashFile = os.path.join(os.getcwd(), "hash.json") - data = dict() - if os.path.isfile(hashFile): - with open(hashFile, "rt") as f: - data = json.load(f) - shouldUpdateMd = data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None - shouldUpdatePot = ( - data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None - ) - shouldAddMdFromScratch = data.get("readmeSha") is None - shouldAddPotFromScratch = data.get("i18nSourcesSha") is None - if readmeSha is not None: - data["readmeSha"] = readmeSha - if i18nSourcesSha is not None: - data["i18nSourcesSha"] = i18nSourcesSha - with open(hashFile, "wt", encoding="utf-8") as f: - json.dump(data, f, indent="\t", ensure_ascii=False) name = "addonId" value = addonId - name0 = "shouldUpdateMd" - value0 = str(shouldUpdateMd).lower() - name1 = "shouldUpdatePot" - value1 = str(shouldUpdatePot).lower() - name2 = "shouldAddMdFromScratch" - value2 = str(shouldAddMdFromScratch).lower() - name3 = "shouldAddPotFromScratch" - value3 = str(shouldAddPotFromScratch).lower() with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") + f.write(f"{name}={value}\n") if __name__ == "__main__": From 0eb3223e395c6c319d8c06c5162ce9dff840c013 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 20 Apr 2026 06:48:46 +0200 Subject: [PATCH 059/142] [crowdinL10n.yml] improve CI translation workflow - move Python helper scripts from .github/workflows to .github/scripts for better separation of concerns - add polib dependency and switch to uv sync for reproducible CI environment - fix missing GH_TOKEN required for GitHub CLI (gh) commands - fix l10nUtil.exe path resolution (use ./l10nUtil.exe instead of _l10n/l10nUtil.exe) - improve Crowdin download behavior by avoiding processing empty translation files - refine PO handling: preserve local translations, conditionally upload to Crowdin when needed - refine XLIFF handling: update local documentation only, no upload back to Crowdin - ensure safer, more deterministic, and more predictable translation synchronization logic --- .github/scripts/checkTranslation.py | 106 +++++++++++++++++++ .github/scripts/setOutputs.py | 21 ++++ .github/workflows/crowdinL10n.yml | 153 +++++++++++++++++++++++----- pyproject.toml | 1 + 4 files changed, 253 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/checkTranslation.py create mode 100644 .github/scripts/setOutputs.py diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py new file mode 100644 index 0000000..5d9e5a4 --- /dev/null +++ b/.github/scripts/checkTranslation.py @@ -0,0 +1,106 @@ +import sys +import os +import xml.etree.ElementTree as ET +import polib + + +def normalize(s: str) -> str: + # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) + return " ".join((s or "").strip().lower().split()) + + +# ----------------------------- +# PO FILE CHECK +# ----------------------------- +def checkPo(path: str) -> float: + # Parse PO file using polib + po = polib.pofile(path) + + translated = 0 + total = 0 + + for entry in po: + # Skip empty msgid entries + if not entry.msgid.strip(): + continue + + total += 1 + + # Consider entry translated only if msgstr differs from msgid + if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): + translated += 1 + + return translated / total if total else 0.0 + + +# ----------------------------- +# XLIFF CHECK (skeleton-safe generic parsing) +# ----------------------------- +def checkXliff(path: str) -> float: + # Parse XML XLIFF file + tree = ET.parse(path) + root = tree.getroot() + + translated = 0 + total = 0 + + source = None + + for elem in root.iter(): + + # Capture source segments + if elem.tag.endswith("source"): + source = normalize(elem.text) + + # Compare with target segments + elif elem.tag.endswith("target"): + target = normalize(elem.text) + + if source: + total += 1 + + # Count as translated only if target differs from source + if target and target != source: + translated += 1 + + return translated / total if total else 0.0 + + +# ----------------------------- +# MAIN ENTRY POINT +# ----------------------------- +def main(): + if len(sys.argv) < 2: + print("Usage: checkTranslation.py ") + sys.exit(2) + + path = sys.argv[1] + + if not os.path.exists(path): + print(f"File not found: {path}") + sys.exit(2) + + ext = os.path.splitext(path)[1].lower() + + # Dispatch based on file type + if ext == ".po": + ratio = checkPo(path) + + elif ext in [".xliff", ".xlf"]: + ratio = checkXliff(path) + + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) + + print(f"translation_ratio={ratio}") + + # Threshold: consider file translated if above 5% + if ratio > 0.05: + sys.exit(0) # translated + else: + sys.exit(1) # not translated + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py new file mode 100644 index 0000000..a53aeb0 --- /dev/null +++ b/.github/scripts/setOutputs.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os +import sys + +sys.path.insert(0, os.getcwd()) +import buildVars + + +def main(): + addonId = buildVars.addon_info["addon_name"] + name = "addonId" + value = addonId + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a9e23db..be509ad 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -1,71 +1,168 @@ name: Crowdin l10n on: - workflow_dispatch: schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n + GH_TOKEN: ${{ github.token }} + jobs: crowdinSync: runs-on: windows-latest permissions: contents: write + steps: - name: Checkout add-on uses: actions/checkout@v6 with: submodules: true - - name: "Set up Python" + + - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install the latest version of uv + + - name: Install uv uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync + - name: Get add-on info id: getAddonInfo shell: pwsh + run: uv run ./.github/scripts/setOutputs.py + + - name: Download l10nUtil run: | - uv sync - uv run ./.github/workflows/setOutputs.py - - name: Download l10nUtil from nvdal10n - run: | - # Download the latest release asset matching the pattern from the specified repository - gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" + gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" + - name: Download translations from Crowdin + shell: pwsh run: | - uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon + ./l10nUtil.exe exportTranslations -o _addonL10n -c addon + New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null - foreach ($dir in Get-ChildItem -Path "_addonL10n/${{ steps.getAddonInfo.outputs.addonId }}" -Directory) { - Write-Host "Processing: $($dir.FullName)" + + $addonId = "${{ steps.getAddonInfo.outputs.addonId }}" + + foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + + Write-Host "==============================" + Write-Host "Processing: $($dir.Name)" + Write-Host "==============================" + + # ============================ + # LANG SETUP (IMPORTANT) + # ============================ $langCode = $dir.Name - $poFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.po" + + # ============================ + # FILE PATHS + # ============================ + $poFile = Join-Path $dir.FullName "$addonId.po" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $targetDocDir = "addon/doc/$langCode" + $readmePath = "$targetDocDir/readme.md" + + # ============================ + # PO PROCESSING + # ============================ if (Test-Path -PathType Leaf $poFile) { - $targetDir = "addon/locale/$langCode/LC_MESSAGES" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null - Write-Host "Moving $poFile to $targetDir/nvda.po" - Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + + Write-Host "Running PO translation check..." + + uv run ./.github/scripts/checkTranslation.py "$poFile" + $isTranslated = ($LASTEXITCODE -eq 0) + + if ($isTranslated) { + + Write-Host "PO translated → updating local repo" + + $targetDir = "addon/locale/$langCode/LC_MESSAGES" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + + Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + + } else { + + Write-Host "PO not translated → skipping local update" + + $addonName = "${{ steps.getAddonInfo.outputs.addonId }}" + $crowdinFile = "$addonName.po" + + if (Test-Path -PathType Leaf $localPoPath) { + + Write-Host "Uploading local PO to Crowdin (no translations found remotely)" + + ./l10nUtil.exe uploadTranslationFile $langCode $crowdinFile $localPoPath -c addon + + } else { + + Write-Host "Local PO file does not exist → skipping upload" + } + } } - $xliffFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.xliff" + + # ============================ + # XLIFF PROCESSING + # ============================ + $isXliffTranslated = $false + if (Test-Path -PathType Leaf $xliffFile) { - $targetDir = "addon/doc/$langCode" - Write-Host "Moving $xliffFile to $targetDir/readme.md" - uv run l10nUtil.exe xliff2md $xliffFile "$targetDir/readme.md" + + Write-Host "Running XLIFF translation check..." + + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + + if ($isXliffTranslated) { + + Write-Host "XLIFF translated → updating README" + + New-Item -ItemType Directory -Force -Path $targetDocDir | Out-Null + + uv run l10nUtil.exe xliff2md $xliffFile $readmePath + + } else { + + Write-Host "XLIFF not translated → skipping README update" + } + + Write-Host "XLIFF translation result: $isXliffTranslated" } } + + # ============================ + # COMMIT CHANGES + # ============================ + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add addon/locale addon/doc - $diff = git diff --staged --quiet - if ($LASTEXITCODE -eq 0) { - Write-Host "Nothing added to commit." - } else { - git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" - git checkout -b ${{ env.downloadTranslationsBranch }} + + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch ${{ env.downloadTranslationsBranch }} 2>$null + + if ($LASTEXITCODE -ne 0) { + git switch -c ${{ env.downloadTranslationsBranch }} + } git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - } + } else { + Write-Host "Nothing to commit." + } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index aa8752d..1f06e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", + "polib", # Lint "uv==0.9.11", "ruff==0.14.5", From a1951107922df6e59d3be324a5f8676c3e8dc7bd Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 20 Apr 2026 08:17:14 +0200 Subject: [PATCH 060/142] Remove duplicate setOutputs.py from .github/workflows The script is already available in .github/scripts/. --- .github/workflows/setOutputs.py | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/setOutputs.py diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py deleted file mode 100644 index a53aeb0..0000000 --- a/.github/workflows/setOutputs.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -import os -import sys - -sys.path.insert(0, os.getcwd()) -import buildVars - - -def main(): - addonId = buildVars.addon_info["addon_name"] - name = "addonId" - value = addonId - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") - - -if __name__ == "__main__": - main() From e62bd5bfc84e9eb6c028c7820b448d251b053c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 18:40:14 +0200 Subject: [PATCH 061/142] Update lock --- uv.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/uv.lock b/uv.lock index e0def29..0ac096f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ dependencies = [ { name = "mdx-gh-links" }, { name = "mdx-truly-sane-lists" }, { name = "nh3" }, + { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, { name = "requests" }, @@ -30,6 +31,7 @@ requires-dist = [ { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, + { name = "polib" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, { name = "requests", specifier = "==2.32.5" }, @@ -270,6 +272,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + [[package]] name = "pre-commit" version = "4.2.0" From 91995bd24a9448bc95ae2bf6b33c0427aa2968be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 19:13:08 +0200 Subject: [PATCH 062/142] Try to fix pre-commit configuration --- .github/scripts/checkTranslation.py | 117 ++++++++++++++-------------- .github/scripts/setOutputs.py | 2 +- .github/workflows/crowdinL10n.yml | 2 +- .pre-commit-config.yaml | 5 +- sha256.py | 11 ++- 5 files changed, 68 insertions(+), 69 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 5d9e5a4..bf39e81 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -4,103 +4,102 @@ import polib -def normalize(s: str) -> str: - # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) - return " ".join((s or "").strip().lower().split()) +def normalize(s: str | None) -> str: + # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) + return " ".join((s or "").strip().lower().split()) # ----------------------------- # PO FILE CHECK # ----------------------------- def checkPo(path: str) -> float: - # Parse PO file using polib - po = polib.pofile(path) + # Parse PO file using polib + po = polib.pofile(path) - translated = 0 - total = 0 + translated = 0 + total = 0 - for entry in po: - # Skip empty msgid entries - if not entry.msgid.strip(): - continue + for entry in po: + # Skip empty msgid entries + if not entry.msgid.strip(): + continue - total += 1 + total += 1 - # Consider entry translated only if msgstr differs from msgid - if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): - translated += 1 + # Consider entry translated only if msgstr differs from msgid + if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): + translated += 1 - return translated / total if total else 0.0 + return translated / total if total else 0.0 # ----------------------------- # XLIFF CHECK (skeleton-safe generic parsing) # ----------------------------- def checkXliff(path: str) -> float: - # Parse XML XLIFF file - tree = ET.parse(path) - root = tree.getroot() + # Parse XML XLIFF file + tree = ET.parse(path) + root = tree.getroot() - translated = 0 - total = 0 + translated = 0 + total = 0 - source = None + source = None - for elem in root.iter(): + for elem in root.iter(): + # Capture source segments + if elem.tag.endswith("source"): + source = normalize(elem.text) - # Capture source segments - if elem.tag.endswith("source"): - source = normalize(elem.text) + # Compare with target segments + elif elem.tag.endswith("target"): + target = normalize(elem.text) - # Compare with target segments - elif elem.tag.endswith("target"): - target = normalize(elem.text) + if source: + total += 1 - if source: - total += 1 + # Count as translated only if target differs from source + if target and target != source: + translated += 1 - # Count as translated only if target differs from source - if target and target != source: - translated += 1 - - return translated / total if total else 0.0 + return translated / total if total else 0.0 # ----------------------------- # MAIN ENTRY POINT # ----------------------------- def main(): - if len(sys.argv) < 2: - print("Usage: checkTranslation.py ") - sys.exit(2) + if len(sys.argv) < 2: + print("Usage: checkTranslation.py ") + sys.exit(2) - path = sys.argv[1] + path = sys.argv[1] - if not os.path.exists(path): - print(f"File not found: {path}") - sys.exit(2) + if not os.path.exists(path): + print(f"File not found: {path}") + sys.exit(2) - ext = os.path.splitext(path)[1].lower() + ext = os.path.splitext(path)[1].lower() - # Dispatch based on file type - if ext == ".po": - ratio = checkPo(path) + # Dispatch based on file type + if ext == ".po": + ratio = checkPo(path) - elif ext in [".xliff", ".xlf"]: - ratio = checkXliff(path) + elif ext in [".xliff", ".xlf"]: + ratio = checkXliff(path) - else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) - print(f"translation_ratio={ratio}") + print(f"translation_ratio={ratio}") - # Threshold: consider file translated if above 5% - if ratio > 0.05: - sys.exit(0) # translated - else: - sys.exit(1) # not translated + # Threshold: consider file translated if above 5% + if ratio > 0.05: + sys.exit(0) # translated + else: + sys.exit(1) # not translated if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index a53aeb0..a5d9161 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -14,7 +14,7 @@ def main(): name = "addonId" value = addonId with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") + _ = f.write(f"{name}={value}\n") if __name__ == "__main__": diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index be509ad..8378d51 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -165,4 +165,4 @@ jobs: git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} } else { Write-Host "Nothing to commit." - } \ No newline at end of file + } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea70058..e8c5026 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,10 +76,11 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff +- repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.4 + hooks: - id: uv-lock name: Verify uv lock file - # Override python interpreter from .python-versions as that is too strict for pre-commit.ci - args: ["-p3.13"] - repo: local hooks: diff --git a/sha256.py b/sha256.py index 51c903b..d2d455b 100644 --- a/sha256.py +++ b/sha256.py @@ -17,17 +17,16 @@ def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = :return: The Sha256 hex digest. """ sha256 = hashlib.sha256() - for f in binaryReadModeFiles: - with open(f, "rb") as file: - assert file.readable() and file.mode == "rb" - for block in iter(lambda: file.read(blockSize), b""): - sha256.update(block) + for file in binaryReadModeFiles: + assert file.readable() and file.mode == "rb" + for block in iter(lambda: file.read(blockSize), b""): + sha256.update(block) return sha256.hexdigest() def main(): parser = argparse.ArgumentParser() - parser.add_argument( + _ = parser.add_argument( type=argparse.FileType("rb"), dest="file", help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", From 0c613be08597470b38cc03fb0fd2ed62f3aaea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 19:53:00 +0200 Subject: [PATCH 063/142] Reset gitignore to master --- .gitignore | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 86b975e..a6ccee5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,12 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv - -# Files generated for add-ons addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -addon/*.ini -addon/locale/*/*.ini +manifest.ini *.mo *.pot -*.pyc +*.py[co] *.nvda-addon .sconsign.dblite +/[0-9]*.[0-9]*.[0-9]*.json *.egg-info From 0b6507252d3e84641c437bbb1996766fdf86ec1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:00:46 +0200 Subject: [PATCH 064/142] Require polib 1.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43e1e84..b9233f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - "polib", + "polib==1.2.0", # Lint "uv==0.11.6", "ruff==0.14.5", From f9ba8fee37e8d5d0ee06faabe34f8950ff7ad2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:01:24 +0200 Subject: [PATCH 065/142] Update lock file --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 2b8a928..5176c15 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ requires-dist = [ { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, - { name = "polib" }, + { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, { name = "requests", specifier = "==2.33.0" }, From 8e514ba304e452d97d03afebf5121107e91f68eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:08:27 +0200 Subject: [PATCH 066/142] Remove sha256 file --- sha256.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 sha256.py diff --git a/sha256.py b/sha256.py deleted file mode 100644 index d2d455b..0000000 --- a/sha256.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (C) 2020-2025 NV Access Limited, Noelia Ruiz Martínez -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - -import argparse -import hashlib -import typing - -#: The read size for each chunk read from the file, prevents memory overuse with large files. -BLOCK_SIZE = 65536 - - -def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = BLOCK_SIZE): - """ - :param binaryReadModeFiles: A list of files (mode=='rb'). Calculate its sha256 hash. - :param blockSize: The size of each read. - :return: The Sha256 hex digest. - """ - sha256 = hashlib.sha256() - for file in binaryReadModeFiles: - assert file.readable() and file.mode == "rb" - for block in iter(lambda: file.read(blockSize), b""): - sha256.update(block) - return sha256.hexdigest() - - -def main(): - parser = argparse.ArgumentParser() - _ = parser.add_argument( - type=argparse.FileType("rb"), - dest="file", - help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", - ) - args = parser.parse_args() - checksum = sha256_checksum(args.file) - print(f"Sha256:\t {checksum}") - - -if __name__ == "__main__": - main() From 9c642478ec4ac0dc9ec0497684c612a2535c009f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:13:41 +0200 Subject: [PATCH 067/142] Remove verification of lock file in pre-commit config, not present in master branch --- .pre-commit-config.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8c5026..f4c3e91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,11 +76,6 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff -- repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.4 - hooks: - - id: uv-lock - name: Verify uv lock file - repo: local hooks: From 7589fef76911f4554a6309482199ba44ea50808e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:21:49 +0200 Subject: [PATCH 068/142] Remove Crowdin client --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9233f1..3d10490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,7 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management - "requests==2.33.0", "nh3==0.3.2", - "crowdin-api-client==1.24.1", "lxml==6.0.2", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", From 94b1a88c995b6904c2505c1d04a3f6b55c2de25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:22:08 +0200 Subject: [PATCH 069/142] Update lock file --- uv.lock | 129 -------------------------------------------------------- 1 file changed, 129 deletions(-) diff --git a/uv.lock b/uv.lock index 5176c15..0701df0 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,6 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ - { name = "crowdin-api-client" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -16,7 +15,6 @@ dependencies = [ { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, - { name = "requests" }, { name = "ruff" }, { name = "scons" }, { name = "uv" }, @@ -24,7 +22,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "crowdin-api-client", specifier = "==1.24.1" }, { name = "lxml", specifier = "==6.0.2" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -34,21 +31,11 @@ requires-dist = [ { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, - { name = "requests", specifier = "==2.33.0" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, { name = "uv", specifier = "==0.11.6" }, ] -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - [[package]] name = "cfgv" version = "3.5.0" @@ -58,56 +45,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "crowdin-api-client" -version = "1.24.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, -] - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -135,15 +72,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - [[package]] name = "lxml" version = "6.0.2" @@ -333,21 +261,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "requests" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, -] - [[package]] name = "ruff" version = "0.14.5" @@ -392,15 +305,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - [[package]] name = "uv" version = "0.11.6" @@ -440,36 +344,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544 wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] - -[[package]] -name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, -] From 65290e4643019111e91b5f658eae3da776c55ffa Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 23 Apr 2026 00:27:29 +0200 Subject: [PATCH 070/142] Enhance Crowdin l10n workflow with MD quality evaluation and comparison logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Markdown language scoring (langid) in checkTranslation.py - Extend script to support MD files and optional multi-file comparison - Update workflow to handle XLIFF → MD conversion only when translated - Implement multi-source comparison (XLIFF MD, remote MD, local MD) - Apply best-quality selection before updating or uploading files - Add full logging for all decision branches - Improve fallback behavior when only one source is available --- .github/scripts/checkTranslation.py | 132 +++++++++++++----- .github/workflows/crowdinL10n.yml | 206 +++++++++++++++++++++------- pyproject.toml | 1 + 3 files changed, 258 insertions(+), 81 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index bf39e81..92100ec 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,78 +2,134 @@ import os import xml.etree.ElementTree as ET import polib - +import langid def normalize(s: str | None) -> str: - # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) return " ".join((s or "").strip().lower().split()) - # ----------------------------- -# PO FILE CHECK +# PO CHECK # ----------------------------- + def checkPo(path: str) -> float: - # Parse PO file using polib po = polib.pofile(path) - translated = 0 total = 0 for entry in po: - # Skip empty msgid entries if not entry.msgid.strip(): continue total += 1 - # Consider entry translated only if msgstr differs from msgid if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): translated += 1 return translated / total if total else 0.0 - # ----------------------------- -# XLIFF CHECK (skeleton-safe generic parsing) +# XLIFF CHECK # ----------------------------- + def checkXliff(path: str) -> float: - # Parse XML XLIFF file tree = ET.parse(path) root = tree.getroot() - translated = 0 total = 0 - source = None for elem in root.iter(): - # Capture source segments if elem.tag.endswith("source"): source = normalize(elem.text) - # Compare with target segments elif elem.tag.endswith("target"): target = normalize(elem.text) if source: total += 1 - - # Count as translated only if target differs from source if target and target != source: translated += 1 return translated / total if total else 0.0 +# ----------------------------- +# MD LANGUAGE SCORE (langid) +# ----------------------------- + +def scoreMd(path: str, expected_lang: str) -> float: + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + except Exception: + return 0.0 + + if not text.strip(): + return 0.0 + + lang, score = langid.classify(text) + + # Normalize score into positive confidence + confidence = 1 / (1 + abs(score)) + + if lang == expected_lang: + return confidence + else: + return 0.0 + +# ----------------------------- +# COMPARE MULTIPLE MD FILES +# ----------------------------- + +def compareMd(files: list[str], lang: str): + results = [] + + for f in files: + if not os.path.exists(f): + continue + + score = scoreMd(f, lang) + results.append((f, score)) + + if not results: + print("winner=None") + sys.exit(1) + + results.sort(key=lambda x: x[1], reverse=True) + + winner = results[0] + + print("comparison_results:") + for f, s in results: + print(f"{f}={s}") + + print(f"winner={winner[0]}") + print(f"winner_score={winner[1]}") + + sys.exit(0) # ----------------------------- -# MAIN ENTRY POINT +# MAIN # ----------------------------- + def main(): if len(sys.argv) < 2: - print("Usage: checkTranslation.py ") + print("Usage:") + print(" checkTranslation.py ") + print(" checkTranslation.py ") + print(" checkTranslation.py [...] ") sys.exit(2) - path = sys.argv[1] + args = sys.argv[1:] + + # ------------------------- + # MULTI FILE MODE + # ------------------------- + if len(args) >= 3: + *files, lang = args + compareMd(files, lang) + return + + path = args[0] if not os.path.exists(path): print(f"File not found: {path}") @@ -81,25 +137,39 @@ def main(): ext = os.path.splitext(path)[1].lower() - # Dispatch based on file type + # ------------------------- + # PO + # ------------------------- if ext == ".po": ratio = checkPo(path) + print(f"translation_ratio={ratio}") + sys.exit(0 if ratio > 0.05 else 1) + # ------------------------- + # XLIFF + # ------------------------- elif ext in [".xliff", ".xlf"]: ratio = checkXliff(path) + print(f"translation_ratio={ratio}") + sys.exit(0 if ratio > 0.05 else 1) - else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + # ------------------------- + # MD (LANG SCORE) + # ------------------------- + elif ext == ".md": + if len(args) < 2: + print("Missing language argument for MD scoring") + sys.exit(2) - print(f"translation_ratio={ratio}") + lang = args[1] + score = scoreMd(path, lang) - # Threshold: consider file translated if above 5% - if ratio > 0.05: - sys.exit(0) # translated - else: - sys.exit(1) # not translated + print(f"md_score={score}") + sys.exit(0) + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 8378d51..f0022c1 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,7 +3,6 @@ name: Crowdin l10n on: workflow_dispatch: schedule: - # Every Monday at 00:00 UTC - cron: '0 0 * * 1' concurrency: @@ -20,7 +19,6 @@ jobs: runs-on: windows-latest permissions: contents: write - steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -60,95 +58,202 @@ jobs: foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" - Write-Host "Processing: $($dir.Name)" + Write-Host "Processing language: $($dir.Name)" Write-Host "==============================" - # ============================ - # LANG SETUP (IMPORTANT) - # ============================ $langCode = $dir.Name + $langShort = $langCode.Split('_')[0] - # ============================ - # FILE PATHS - # ============================ + # Paths $poFile = Join-Path $dir.FullName "$addonId.po" $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $remoteMd = Join-Path $dir.FullName "$addonId.md" + $targetDocDir = "addon/doc/$langCode" - $readmePath = "$targetDocDir/readme.md" + $localMd = "$targetDocDir/readme.md" + + # ---------------------------- + # SKIP ENGLISH (source language) + # ---------------------------- + if ($langCode -eq "en") { + Write-Host "Skipping English (source language) → no MD/XLIFF processing required" + continue + } - # ============================ + # ---------------------------- # PO PROCESSING - # ============================ - if (Test-Path -PathType Leaf $poFile) { - - Write-Host "Running PO translation check..." + # ---------------------------- + if (Test-Path $poFile) { + Write-Host "Checking PO file..." uv run ./.github/scripts/checkTranslation.py "$poFile" - $isTranslated = ($LASTEXITCODE -eq 0) + $isPoTranslated = ($LASTEXITCODE -eq 0) - if ($isTranslated) { + Write-Host "PO translated: $isPoTranslated" - Write-Host "PO translated → updating local repo" + if ($isPoTranslated) { + Write-Host "Updating local PO" + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + Move-Item $poFile $localPoPath -Force + } else { + Write-Host "PO not translated" + if (Test-Path $localPoPath) { + Write-Host "Uploading local PO to Crowdin" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon + } else { + Write-Host "No local PO available" + } + } + } - $targetDir = "addon/locale/$langCode/LC_MESSAGES" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + # ---------------------------- + # XLIFF PROCESSING + # ---------------------------- + $xliffValid = $false + $tempMd = $null - Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + if (Test-Path $xliffFile) { + Write-Host "Checking XLIFF..." - } else { + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $xliffValid = ($LASTEXITCODE -eq 0) + + Write-Host "XLIFF valid: $xliffValid" + + if ($xliffValid) { + Write-Host "Converting XLIFF → MD" + $tempMd = "$env:TEMP\readme_$langCode.md" + ./l10nUtil.exe xliff2md $xliffFile $tempMd + } + } + + $remoteExists = Test-Path $remoteMd + $localExists = Test-Path $localMd + + Write-Host "Remote MD exists: $remoteExists" + Write-Host "Local MD exists: $localExists" + + # ---------------------------- + # DECISION ENGINE + # ---------------------------- + + # CASE: XLIFF VALID + if ($xliffValid) { + Write-Host "Entering XLIFF-driven logic" + + if ($remoteExists -and $localExists) { + Write-Host "3-way comparison (xliff, remote, local)" + + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + + $scoreX = [double]$scoreX + $scoreR = [double]$scoreR + $scoreL = [double]$scoreL - Write-Host "PO not translated → skipping local update" + Write-Host "Scores → XLIFF:$scoreX Remote:$scoreR Local:$scoreL" - $addonName = "${{ steps.getAddonInfo.outputs.addonId }}" - $crowdinFile = "$addonName.po" + $best = [Math]::Max($scoreX, [Math]::Max($scoreR, $scoreL)) - if (Test-Path -PathType Leaf $localPoPath) { + if ($best -eq $scoreX) { + Write-Host "Winner: XLIFF" + Move-Item $tempMd $localMd -Force + } elseif ($best -eq $scoreR) { + Write-Host "Winner: Remote MD" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Winner: Local MD → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } + + } elseif ($remoteExists -and -not $localExists) { + Write-Host "Comparing XLIFF vs Remote" - Write-Host "Uploading local PO to Crowdin (no translations found remotely)" + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - ./l10nUtil.exe uploadTranslationFile $langCode $crowdinFile $localPoPath -c addon + $scoreX = [double]$scoreX + $scoreR = [double]$scoreR + if ($scoreX -ge $scoreR) { + Write-Host "Winner: XLIFF → creating local" + Move-Item $tempMd $localMd -Force } else { + Write-Host "Winner: Remote → creating local" + Move-Item $remoteMd $localMd -Force + } + + } elseif (-not $remoteExists -and $localExists) { + Write-Host "Comparing XLIFF vs Local" + + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - Write-Host "Local PO file does not exist → skipping upload" + $scoreX = [double]$scoreX + $scoreL = [double]$scoreL + + if ($scoreX -gt $scoreL) { + Write-Host "Winner: XLIFF → overwrite local" + Move-Item $tempMd $localMd -Force + } else { + Write-Host "Winner: Local → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon } + + } else { + Write-Host "Only XLIFF available → importing directly" + Move-Item $tempMd $localMd -Force } - } - # ============================ - # XLIFF PROCESSING - # ============================ - $isXliffTranslated = $false + } else { + Write-Host "XLIFF not usable → fallback logic" - if (Test-Path -PathType Leaf $xliffFile) { + if ($remoteExists -and $localExists) { + Write-Host "Comparing Remote vs Local" - Write-Host "Running XLIFF translation check..." + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) + $scoreR = [double]$scoreR + $scoreL = [double]$scoreL - if ($isXliffTranslated) { + if ($scoreR -gt $scoreL) { + Write-Host "Winner: Remote → overwrite local" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Winner: Local → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } - Write-Host "XLIFF translated → updating README" + } elseif ($remoteExists -and -not $localExists) { + Write-Host "Remote only → checking quality" - New-Item -ItemType Directory -Force -Path $targetDocDir | Out-Null + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = [double]$scoreR - uv run l10nUtil.exe xliff2md $xliffFile $readmePath + if ($scoreR -gt 0.5) { + Write-Host "Remote is valid → importing" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Remote not valid → skipping" + } - } else { + } elseif (-not $remoteExists -and $localExists) { + Write-Host "Only local exists → uploading without scoring" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - Write-Host "XLIFF not translated → skipping README update" + } else { + Write-Host "No MD available → nothing to do" } - - Write-Host "XLIFF translation result: $isXliffTranslated" } } - # ============================ - # COMMIT CHANGES - # ============================ + # ---------------------------- + # COMMIT + # ---------------------------- git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -157,11 +262,12 @@ jobs: git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" - git switch ${{ env.downloadTranslationsBranch }} 2>$null + git switch ${{ env.downloadTranslationsBranch }} 2>$null if ($LASTEXITCODE -ne 0) { git switch -c ${{ env.downloadTranslationsBranch }} } + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} } else { Write-Host "Nothing to commit." diff --git a/pyproject.toml b/pyproject.toml index 3d10490..e1b8a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", "polib==1.2.0", + "langid==1.1.6", # Lint "uv==0.11.6", "ruff==0.14.5", From c9c3bab10735c087f9494896d9316e8bcd959683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 23 Apr 2026 01:37:40 +0200 Subject: [PATCH 071/142] Run pre-commit --- .github/scripts/checkTranslation.py | 14 +++++++++- uv.lock | 40 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 92100ec..be6ca0b 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -4,13 +4,16 @@ import polib import langid + def normalize(s: str | None) -> str: return " ".join((s or "").strip().lower().split()) + # ----------------------------- # PO CHECK # ----------------------------- + def checkPo(path: str) -> float: po = polib.pofile(path) translated = 0 @@ -27,10 +30,12 @@ def checkPo(path: str) -> float: return translated / total if total else 0.0 + # ----------------------------- # XLIFF CHECK # ----------------------------- + def checkXliff(path: str) -> float: tree = ET.parse(path) root = tree.getroot() @@ -52,10 +57,12 @@ def checkXliff(path: str) -> float: return translated / total if total else 0.0 + # ----------------------------- # MD LANGUAGE SCORE (langid) # ----------------------------- + def scoreMd(path: str, expected_lang: str) -> float: try: with open(path, "r", encoding="utf-8") as f: @@ -76,10 +83,12 @@ def scoreMd(path: str, expected_lang: str) -> float: else: return 0.0 + # ----------------------------- # COMPARE MULTIPLE MD FILES # ----------------------------- + def compareMd(files: list[str], lang: str): results = [] @@ -107,10 +116,12 @@ def compareMd(files: list[str], lang: str): sys.exit(0) + # ----------------------------- # MAIN # ----------------------------- + def main(): if len(sys.argv) < 2: print("Usage:") @@ -171,5 +182,6 @@ def main(): print(f"Unsupported file type: {ext}") sys.exit(2) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/uv.lock b/uv.lock index 0701df0..a5ffac5 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ + { name = "langid" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -22,6 +23,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "langid", specifier = "==1.1.6" }, { name = "lxml", specifier = "==6.0.2" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -72,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] +[[package]] +name = "langid" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } + [[package]] name = "lxml" version = "6.0.2" @@ -191,6 +202,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" }, ] +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" From b1d2acfb1ab26cb552a16a1bfbaddbc7d11d724f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 05:30:55 +0200 Subject: [PATCH 072/142] Restore deleted space --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4c3e91..207177d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,6 +76,7 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff + - repo: local hooks: From 263b7e22354939e72eb11bfedb67087adf73139a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 05:32:43 +0200 Subject: [PATCH 073/142] Update lock file --- uv.lock | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bb4070f --- /dev/null +++ b/uv.lock @@ -0,0 +1,400 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "langid" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "polib" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "langid", specifier = "==1.1.6" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, + { name = "polib", specifier = "==1.2.0" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "ruff", specifier = "==0.14.5" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.11.6" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "langid" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, + { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uv" +version = "0.11.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, + { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, + { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, + { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, + { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, + { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, + { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, +] From f6e18c281f120ab3832a101cf1a7aba46ad19f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:12:46 +0200 Subject: [PATCH 074/142] Remove score staff --- .github/scripts/checkTranslation.py | 65 ------------- .github/workflows/crowdinL10n.yml | 145 +++------------------------- pyproject.toml | 1 - 3 files changed, 11 insertions(+), 200 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index be6ca0b..eff95d3 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,7 +2,6 @@ import os import xml.etree.ElementTree as ET import polib -import langid def normalize(s: str | None) -> str: @@ -58,70 +57,6 @@ def checkXliff(path: str) -> float: return translated / total if total else 0.0 -# ----------------------------- -# MD LANGUAGE SCORE (langid) -# ----------------------------- - - -def scoreMd(path: str, expected_lang: str) -> float: - try: - with open(path, "r", encoding="utf-8") as f: - text = f.read() - except Exception: - return 0.0 - - if not text.strip(): - return 0.0 - - lang, score = langid.classify(text) - - # Normalize score into positive confidence - confidence = 1 / (1 + abs(score)) - - if lang == expected_lang: - return confidence - else: - return 0.0 - - -# ----------------------------- -# COMPARE MULTIPLE MD FILES -# ----------------------------- - - -def compareMd(files: list[str], lang: str): - results = [] - - for f in files: - if not os.path.exists(f): - continue - - score = scoreMd(f, lang) - results.append((f, score)) - - if not results: - print("winner=None") - sys.exit(1) - - results.sort(key=lambda x: x[1], reverse=True) - - winner = results[0] - - print("comparison_results:") - for f, s in results: - print(f"{f}={s}") - - print(f"winner={winner[0]}") - print(f"winner_score={winner[1]}") - - sys.exit(0) - - -# ----------------------------- -# MAIN -# ----------------------------- - - def main(): if len(sys.argv) < 2: print("Usage:") diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index f0022c1..2d24165 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -69,16 +69,14 @@ jobs: $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $remoteMd = Join-Path $dir.FullName "$addonId.md" - $targetDocDir = "addon/doc/$langCode" - $localMd = "$targetDocDir/readme.md" + $mdFile = Join-Path $targetDocDir "readme.md" # ---------------------------- # SKIP ENGLISH (source language) # ---------------------------- if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no MD/XLIFF processing required" + Write-Host "Skipping English (source language) → no XLIFF processing required" continue } @@ -111,147 +109,26 @@ jobs: # ---------------------------- # XLIFF PROCESSING # ---------------------------- - $xliffValid = $false - $tempMd = $null if (Test-Path $xliffFile) { Write-Host "Checking XLIFF..." uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $xliffValid = ($LASTEXITCODE -eq 0) - - Write-Host "XLIFF valid: $xliffValid" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + Write-Host "XLIFF translated: $isXliffTranslated" - if ($xliffValid) { + if ($isXliffTranslated) { Write-Host "Converting XLIFF → MD" - $tempMd = "$env:TEMP\readme_$langCode.md" - ./l10nUtil.exe xliff2md $xliffFile $tempMd + ./l10nUtil.exe xliff2md $xliffFile $mdFile } + else { + Write-Host "XLIFF not valid" } - - $remoteExists = Test-Path $remoteMd - $localExists = Test-Path $localMd - - Write-Host "Remote MD exists: $remoteExists" - Write-Host "Local MD exists: $localExists" - - # ---------------------------- - # DECISION ENGINE - # ---------------------------- - - # CASE: XLIFF VALID - if ($xliffValid) { - Write-Host "Entering XLIFF-driven logic" - - if ($remoteExists -and $localExists) { - Write-Host "3-way comparison (xliff, remote, local)" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreR = [double]$scoreR - $scoreL = [double]$scoreL - - Write-Host "Scores → XLIFF:$scoreX Remote:$scoreR Local:$scoreL" - - $best = [Math]::Max($scoreX, [Math]::Max($scoreR, $scoreL)) - - if ($best -eq $scoreX) { - Write-Host "Winner: XLIFF" - Move-Item $tempMd $localMd -Force - } elseif ($best -eq $scoreR) { - Write-Host "Winner: Remote MD" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Winner: Local MD → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } elseif ($remoteExists -and -not $localExists) { - Write-Host "Comparing XLIFF vs Remote" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreR = [double]$scoreR - - if ($scoreX -ge $scoreR) { - Write-Host "Winner: XLIFF → creating local" - Move-Item $tempMd $localMd -Force - } else { - Write-Host "Winner: Remote → creating local" - Move-Item $remoteMd $localMd -Force - } - - } elseif (-not $remoteExists -and $localExists) { - Write-Host "Comparing XLIFF vs Local" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreL = [double]$scoreL - - if ($scoreX -gt $scoreL) { - Write-Host "Winner: XLIFF → overwrite local" - Move-Item $tempMd $localMd -Force - } else { - Write-Host "Winner: Local → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } else { - Write-Host "Only XLIFF available → importing directly" - Move-Item $tempMd $localMd -Force - } - - } else { - Write-Host "XLIFF not usable → fallback logic" - - if ($remoteExists -and $localExists) { - Write-Host "Comparing Remote vs Local" - - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreR = [double]$scoreR - $scoreL = [double]$scoreL - - if ($scoreR -gt $scoreL) { - Write-Host "Winner: Remote → overwrite local" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Winner: Local → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } elseif ($remoteExists -and -not $localExists) { - Write-Host "Remote only → checking quality" - - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = [double]$scoreR - - if ($scoreR -gt 0.5) { - Write-Host "Remote is valid → importing" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Remote not valid → skipping" - } - - } elseif (-not $remoteExists -and $localExists) { - Write-Host "Only local exists → uploading without scoring" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - - } else { - Write-Host "No MD available → nothing to do" - } + else { + Write-Host "No XLIFF file found" } - } - # ---------------------------- + # COMMIT # ---------------------------- git config user.name "github-actions[bot]" diff --git a/pyproject.toml b/pyproject.toml index becca69..544accf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dependencies = [ "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", "polib==1.2.0", - "langid==1.1.6", # Lint "uv==0.11.6", "ruff==0.14.5", From 55730616e76debfb53f76aa23e00fc0fd491b718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:41:59 +0200 Subject: [PATCH 075/142] Add ps1 script and rename setOutputs.py --- .github/scripts/crowdinSync.ps1 | 73 +++++++++++++++++++ .../{setOutputs.py => getAddonInfo.py} | 9 +-- 2 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/crowdinSync.ps1 rename .github/scripts/{setOutputs.py => getAddonInfo.py} (64%) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 new file mode 100644 index 0000000..5bcff67 --- /dev/null +++ b/.github/scripts/crowdinSync.ps1 @@ -0,0 +1,73 @@ +write-host "Exporting translations from Crowdin..." +./l10nUtil.exe exportTranslations -o _addonL10n -c addon + +New-Item -ItemType Directory -Force -Path addon/locale | Out-Null +New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +Write-Host "Getting addon ID..." +$addonId = python ./.github/scripts/getAddonInfo.py 2>$null +$addonId = $addonId.Trim() + +foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + Write-Host "==============================" + Write-Host "Processing language: $($dir.Name)" + Write-Host "==============================" + + $langCode = $dir.Name + + # Paths + $poFile = Join-Path $dir.FullName "$addonId.po" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $targetDocDir = "addon/doc/$langCode" + $mdFile = Join-Path $targetDocDir "readme.md" + + # ---------------------------- + # SKIP ENGLISH (source language) + # ---------------------------- + if ($langCode -eq "en") { + Write-Host "Skipping English (source language) → no XLIFF processing required" + continue + } + + # ---------------------------- + # PO PROCESSING + # ---------------------------- + if (Test-Path $poFile) { + Write-Host "Checking PO file..." + uv run ./.github/scripts/checkTranslation.py "$poFile" + $isPoTranslated = ($LASTEXITCODE -eq 0) + Write-Host "PO translated: $isPoTranslated" + if ($isPoTranslated) { + Write-Host "Updating local PO" + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + Move-Item $poFile $localPoPath -Force + } else { + Write-Host "PO not translated" + if (Test-Path $localPoPath) { + Write-Host "Uploading local PO to Crowdin" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon + } else { + Write-Host "No local PO available" + } + } + } + + # ---------------------------- + # XLIFF PROCESSING + # ---------------------------- + if (Test-Path $xliffFile) { + Write-Host "Checking XLIFF..." + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + Write-Host "XLIFF translated: $isXliffTranslated" + if ($isXliffTranslated) { + Write-Host "Converting XLIFF → MD" + ./l10nUtil.exe xliff2md $xliffFile $mdFile + } else { + Write-Host "XLIFF not valid" + } + } else { + Write-Host "No XLIFF file found" + } +} # End foreach diff --git a/.github/scripts/setOutputs.py b/.github/scripts/getAddonInfo.py similarity index 64% rename from .github/scripts/setOutputs.py rename to .github/scripts/getAddonInfo.py index a5d9161..5006c6c 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/getAddonInfo.py @@ -11,11 +11,4 @@ def main(): addonId = buildVars.addon_info["addon_name"] - name = "addonId" - value = addonId - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - _ = f.write(f"{name}={value}\n") - - -if __name__ == "__main__": - main() + print(addonId) From 87cbed41e5abe631081637e9192fa630c8e677f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:46:02 +0200 Subject: [PATCH 076/142] Run ps1 script from workflow --- .github/workflows/crowdinL10n.yml | 81 +------------------------------ 1 file changed, 1 insertion(+), 80 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 2d24165..a8fe391 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -48,86 +48,7 @@ jobs: - name: Download translations from Crowdin shell: pwsh run: | - ./l10nUtil.exe exportTranslations -o _addonL10n -c addon - - New-Item -ItemType Directory -Force -Path addon/locale | Out-Null - New-Item -ItemType Directory -Force -Path addon/doc | Out-Null - - $addonId = "${{ steps.getAddonInfo.outputs.addonId }}" - - foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - - Write-Host "==============================" - Write-Host "Processing language: $($dir.Name)" - Write-Host "==============================" - - $langCode = $dir.Name - $langShort = $langCode.Split('_')[0] - - # Paths - $poFile = Join-Path $dir.FullName "$addonId.po" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" - - $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $targetDocDir = "addon/doc/$langCode" - $mdFile = Join-Path $targetDocDir "readme.md" - - # ---------------------------- - # SKIP ENGLISH (source language) - # ---------------------------- - if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no XLIFF processing required" - continue - } - - # ---------------------------- - # PO PROCESSING - # ---------------------------- - if (Test-Path $poFile) { - Write-Host "Checking PO file..." - - uv run ./.github/scripts/checkTranslation.py "$poFile" - $isPoTranslated = ($LASTEXITCODE -eq 0) - - Write-Host "PO translated: $isPoTranslated" - - if ($isPoTranslated) { - Write-Host "Updating local PO" - New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null - Move-Item $poFile $localPoPath -Force - } else { - Write-Host "PO not translated" - if (Test-Path $localPoPath) { - Write-Host "Uploading local PO to Crowdin" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon - } else { - Write-Host "No local PO available" - } - } - } - - # ---------------------------- - # XLIFF PROCESSING - # ---------------------------- - - if (Test-Path $xliffFile) { - Write-Host "Checking XLIFF..." - - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) - Write-Host "XLIFF translated: $isXliffTranslated" - - if ($isXliffTranslated) { - Write-Host "Converting XLIFF → MD" - ./l10nUtil.exe xliff2md $xliffFile $mdFile - } - else { - Write-Host "XLIFF not valid" - } - else { - Write-Host "No XLIFF file found" - } - + ./.github/scripts/crowdinSync.ps1 # COMMIT # ---------------------------- From 1a18717bbe9f15d6791b5cc42f30ede1b66eceb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:48:51 +0200 Subject: [PATCH 077/142] Change python version to 3.13 --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 2c45fe3..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13.11 +3.13 From 471a320d9b2195302f693d8fec65be117bd6cc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:49:53 +0200 Subject: [PATCH 078/142] Update lock file --- uv.lock | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/uv.lock b/uv.lock index bb4070f..4bfb01f 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,6 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ - { name = "langid" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -23,7 +22,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langid", specifier = "==1.1.6" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -74,15 +72,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] -[[package]] -name = "langid" -version = "1.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } - [[package]] name = "lxml" version = "6.1.0" @@ -202,35 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, ] -[[package]] -name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" From e50a7169370ebbb34117db668960913399166ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 07:00:22 +0200 Subject: [PATCH 079/142] Fix checkTranslation script according to pre-commit --- .github/scripts/checkTranslation.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index eff95d3..95262b7 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -61,20 +61,10 @@ def main(): if len(sys.argv) < 2: print("Usage:") print(" checkTranslation.py ") - print(" checkTranslation.py ") - print(" checkTranslation.py [...] ") sys.exit(2) args = sys.argv[1:] - # ------------------------- - # MULTI FILE MODE - # ------------------------- - if len(args) >= 3: - *files, lang = args - compareMd(files, lang) - return - path = args[0] if not os.path.exists(path): @@ -99,20 +89,6 @@ def main(): print(f"translation_ratio={ratio}") sys.exit(0 if ratio > 0.05 else 1) - # ------------------------- - # MD (LANG SCORE) - # ------------------------- - elif ext == ".md": - if len(args) < 2: - print("Missing language argument for MD scoring") - sys.exit(2) - - lang = args[1] - score = scoreMd(path, lang) - - print(f"md_score={score}") - sys.exit(0) - else: print(f"Unsupported file type: {ext}") sys.exit(2) From f5150cb7a5a6cb0ac68b7f25f2eec13b63b52501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 07:16:20 +0200 Subject: [PATCH 080/142] Remove getAddonInfo step --- .github/workflows/crowdinL10n.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a8fe391..d13f572 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -36,11 +36,6 @@ jobs: - name: Install dependencies run: uv sync - - name: Get add-on info - id: getAddonInfo - shell: pwsh - run: uv run ./.github/scripts/setOutputs.py - - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" From 3c7093ccf8ab0c0779a5bac868757b48d652e931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 05:02:21 +0200 Subject: [PATCH 081/142] Update scripts and workflow --- .github/scripts/crowdinSync.ps1 | 32 +++++++++++++++--- .../{getAddonInfo.py => setOutputs.py} | 9 ++++- .github/workflows/crowdinL10n.yml | 33 ++++++------------- 3 files changed, 46 insertions(+), 28 deletions(-) rename .github/scripts/{getAddonInfo.py => setOutputs.py} (65%) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 5bcff67..30da569 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,11 +1,16 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = 'Stop' + write-host "Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -Write-Host "Getting addon ID..." -$addonId = python ./.github/scripts/getAddonInfo.py 2>$null -$addonId = $addonId.Trim() +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." + exit 1 +} foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" @@ -65,9 +70,28 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "Converting XLIFF → MD" ./l10nUtil.exe xliff2md $xliffFile $mdFile } else { - Write-Host "XLIFF not valid" + Write-Host "XLIFF not translated" } } else { Write-Host "No XLIFF file found" } } # End foreach + + # COMMIT CHANGES + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add addon/locale addon/doc + + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch $env:downloadTranslationsBranch 2>$null + + if ($LASTEXITCODE -ne 0) { + git switch -c $env:downloadTranslationsBranch + } + git push -f --set-upstream origin $env:downloadTranslationsBranch + } else { + Write-Host "Nothing to commit." + } \ No newline at end of file diff --git a/.github/scripts/getAddonInfo.py b/.github/scripts/setOutputs.py similarity index 65% rename from .github/scripts/getAddonInfo.py rename to .github/scripts/setOutputs.py index 5006c6c..a53aeb0 100644 --- a/.github/scripts/getAddonInfo.py +++ b/.github/scripts/setOutputs.py @@ -11,4 +11,11 @@ def main(): addonId = buildVars.addon_info["addon_name"] - print(addonId) + name = "addonId" + value = addonId + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index d13f572..3fd37d8 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,6 +3,7 @@ name: Crowdin l10n on: workflow_dispatch: schedule: + # Every Monday at 00:00 UTC - cron: '0 0 * * 1' concurrency: @@ -19,6 +20,7 @@ jobs: runs-on: windows-latest permissions: contents: write + steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -36,32 +38,17 @@ jobs: - name: Install dependencies run: uv sync + - name: Get add-on info + id: getAddonInfo + shell: pwsh + run: uv run ./.github/scripts/setOutputs.py + - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - name: Download translations from Crowdin shell: pwsh - run: | - ./.github/scripts/crowdinSync.ps1 - - # COMMIT - # ---------------------------- - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add addon/locale addon/doc - - git diff --staged --quiet - if ($LASTEXITCODE -ne 0) { - git commit -m "Update translations for $addonId from Crowdin" - - git switch ${{ env.downloadTranslationsBranch }} 2>$null - if ($LASTEXITCODE -ne 0) { - git switch -c ${{ env.downloadTranslationsBranch }} - } - - git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - } else { - Write-Host "Nothing to commit." - } + env: + ADDON_ID: ${{ steps.getAddonInfo.outputs.addonId }} + run: ./.github/scripts/crowdinSync.ps1 From 065a16fa87ba79aa352c44a1ac62565a656710a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 09:32:14 +0200 Subject: [PATCH 082/142] Fix pyright --- .github/scripts/crowdinSync.ps1 | 2 +- .github/scripts/setOutputs.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 30da569..418d97a 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -94,4 +94,4 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { git push -f --set-upstream origin $env:downloadTranslationsBranch } else { Write-Host "Nothing to commit." - } \ No newline at end of file + } diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index a53aeb0..a5d9161 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -14,7 +14,7 @@ def main(): name = "addonId" value = addonId with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") + _ = f.write(f"{name}={value}\n") if __name__ == "__main__": From c4dd5210304d1e2ee417edc1f1288b3da0c450d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 22:21:57 +0200 Subject: [PATCH 083/142] Add markdownTranslate --- .github/scripts/markdownTranslate.py | 881 +++++++++++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 .github/scripts/markdownTranslate.py diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py new file mode 100644 index 0000000..e276049 --- /dev/null +++ b/.github/scripts/markdownTranslate.py @@ -0,0 +1,881 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import Generator +from collections.abc import Iterable +import tempfile +import os +import contextlib +import lxml.etree +import argparse +import uuid +import re +from itertools import zip_longest +from xml.sax.saxutils import escape as xmlEscape +import difflib +from dataclasses import dataclass +import subprocess + + +def getGithubRepoURL() -> str | None: + """ + Get the GitHub repository URL from git remote origin. + return: The raw GitHub URL for the repository, or None if it cannot be determined. + """ + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + remote_url = result.stdout.strip() + # Convert SSH or HTTPS URL to raw GitHub URL format + if match := re.match(r"git@github\.com:(.+?)(?:\.git)?$", remote_url): + repo_path = match.group(1) + elif match := re.match(r"https://github\.com/(.+?)(?:\.git)?$", remote_url): + repo_path = match.group(1) + else: + raise ValueError(f"Cannot parse GitHub URL from git remote: {remote_url}") + return f"https://raw.githubusercontent.com/{repo_path}" + + +re_kcTitle = re.compile(r"^()$") +re_kcSettingsSection = re.compile(r"^()$") +# Comments that span a single line in their entirety +re_comment = re.compile(r"^$") +re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") +re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") +re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") +re_hiddenHeaderRow = re.compile(r"^\|\s*\.\s*\{\.hideHeaderRow\}\s*(\|\s*\.\s*)*\|$") +re_postTableHeaderLine = re.compile(r"^(\|\s*-+\s*)+\|$") +re_tableRow = re.compile(r"^(\|)(.+)(\|)$") +re_translationID = re.compile(r"^(.*)\$\(ID:([0-9a-f-]+)\)(.*)$") +re_inlineMarkdownLintComment = re.compile(r"^(.*?)(?:\s*)(\s*)$") + + +def prettyPathString(path: str) -> str: + cwd = os.getcwd() + if os.path.normcase(os.path.splitdrive(path)[0]) != os.path.normcase( + os.path.splitdrive(cwd)[0], + ): + return path + return os.path.relpath(path, cwd) + + +@contextlib.contextmanager +def createAndDeleteTempFilePath_contextManager( + dir: str | None = None, + prefix: str | None = None, + suffix: str | None = None, +) -> Generator[str, None, None]: + """A context manager that creates a temporary file and deletes it when the context is exited""" + with tempfile.NamedTemporaryFile( + dir=dir, + prefix=prefix, + suffix=suffix, + delete=False, + ) as tempFile: + tempFilePath = tempFile.name + tempFile.close() + yield tempFilePath + os.remove(tempFilePath) + + +def getLastCommitID(filePath: str) -> str: + # Run the git log command to get the last commit ID for the given file + result = subprocess.run( + ["git", "log", "-n", "1", "--pretty=format:%H", "--", filePath], + capture_output=True, + text=True, + check=True, + ) + commitID = result.stdout.strip() + if not re.match(r"[0-9a-f]{40}", commitID): + raise ValueError(f"Invalid commit ID: '{commitID}' for file '{filePath}'") + return commitID + + +def getGitDir() -> str: + # Run the git rev-parse command to get the root of the git directory + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + gitDir = result.stdout.strip() + if not os.path.isdir(gitDir): + raise ValueError(f"Invalid git directory: '{gitDir}'") + return gitDir + + +def getRawGithubURLForPath(filePath: str) -> str: + gitDirPath = getGitDir() + commitID = getLastCommitID(filePath) + relativePath = os.path.relpath(os.path.abspath(filePath), gitDirPath) + relativePath = relativePath.replace("\\", "/") + rawGithubRepoUrl = getGithubRepoURL() + return f"{rawGithubRepoUrl}/{commitID}/{relativePath}" + + +def preprocessMarkdownLines(mdLines: Iterable[str]) -> Iterable[str]: + """ + Preprocess markdown lines such as removing inline markdown lint comments.\ + :param mdLines: The markdown lines to preprocess + :returns: The preprocessed markdown lines + """ + for mdLine in mdLines: + # #18982: Remove markdown lint comments completely - not needed for intermediate markdown or final html. + mdLine = re_inlineMarkdownLintComment.sub(r"\1\2", mdLine) + yield mdLine + + +def skeletonizeLine(mdLine: str) -> str | None: + prefix = "" + suffix = "" + if ( + mdLine.isspace() + or mdLine.strip() == "[TOC]" + or re_hiddenHeaderRow.match(mdLine) + or re_postTableHeaderLine.match(mdLine) + ): + return None + elif m := re_heading.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_bullet.match(mdLine): + prefix, content = m.groups() + elif m := re_number.match(mdLine): + prefix, content = m.groups() + elif m := re_tableRow.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_kcTitle.match(mdLine): + prefix, content, suffix = m.groups() + elif m := re_kcSettingsSection.match(mdLine): + prefix, content, suffix = m.groups() + elif re_comment.match(mdLine): + return None + ID = str(uuid.uuid4()) + return f"{prefix}$(ID:{ID}){suffix}\n" + + +@dataclass +class Result_generateSkeleton: + numTotalLines: int = 0 + numTranslationPlaceholders: int = 0 + + +def generateSkeleton(mdPath: str, outputPath: str) -> Result_generateSkeleton: + print( + f"Generating skeleton file {prettyPathString(outputPath)} from {prettyPathString(mdPath)}...", + ) + res = Result_generateSkeleton() + with ( + open(mdPath, "r", encoding="utf8") as mdFile, + open(outputPath, "w", encoding="utf8", newline="") as outputFile, + ): + for mdLine in preprocessMarkdownLines(mdFile.readlines()): + res.numTotalLines += 1 + skelLine = skeletonizeLine(mdLine) + if skelLine: + res.numTranslationPlaceholders += 1 + else: + skelLine = mdLine + outputFile.write(skelLine) + print( + f"Generated skeleton file with {res.numTotalLines} total lines and {res.numTranslationPlaceholders} translation placeholders", + ) + return res + + +@dataclass +class Result_updateSkeleton: + numAddedLines: int = 0 + numAddedTranslationPlaceholders: int = 0 + numRemovedLines: int = 0 + numRemovedTranslationPlaceholders: int = 0 + numUnchangedLines: int = 0 + numUnchangedTranslationPlaceholders: int = 0 + + +def extractSkeleton(xliffPath: str, outputPath: str): + print( + f"Extracting skeleton from {prettyPathString(xliffPath)} to {prettyPathString(outputPath)}...", + ) + with contextlib.ExitStack() as stack: + outputFile = stack.enter_context( + open(outputPath, "w", encoding="utf8", newline=""), + ) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + skeletonNode = xliffRoot.find( + "./xliff:file/xliff:skeleton", + namespaces=namespace, + ) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + outputFile.write(skeletonContent) + print(f"Extracted skeleton to {prettyPathString(outputPath)}") + + +def updateSkeleton( + origMdPath: str, + newMdPath: str, + origSkelPath: str, + outputPath: str, +) -> Result_updateSkeleton: + print( + f"Creating updated skeleton file {prettyPathString(outputPath)} from {prettyPathString(origSkelPath)} with changes from {prettyPathString(origMdPath)} to {prettyPathString(newMdPath)}...", + ) + res = Result_updateSkeleton() + with contextlib.ExitStack() as stack: + origMdFile = stack.enter_context(open(origMdPath, "r", encoding="utf8")) + newMdFile = stack.enter_context(open(newMdPath, "r", encoding="utf8")) + origSkelFile = stack.enter_context(open(origSkelPath, "r", encoding="utf8")) + outputFile = stack.enter_context( + open(outputPath, "w", encoding="utf8", newline=""), + ) + origMdLines = preprocessMarkdownLines(origMdFile.readlines()) + newMdLines = preprocessMarkdownLines(newMdFile.readlines()) + mdDiff = difflib.ndiff(list(origMdLines), list(newMdLines)) + origSkelLines = iter(origSkelFile.readlines()) + for mdDiffLine in mdDiff: + if mdDiffLine.startswith("?"): + continue + if mdDiffLine.startswith(" "): + res.numUnchangedLines += 1 + skelLine = next(origSkelLines) + if re_translationID.match(skelLine): + res.numUnchangedTranslationPlaceholders += 1 + outputFile.write(skelLine) + elif mdDiffLine.startswith("+"): + res.numAddedLines += 1 + skelLine = skeletonizeLine(mdDiffLine[2:]) + if skelLine: + res.numAddedTranslationPlaceholders += 1 + else: + skelLine = mdDiffLine[2:] + outputFile.write(skelLine) + elif mdDiffLine.startswith("-"): + res.numRemovedLines += 1 + origSkelLine = next(origSkelLines) + if re_translationID.match(origSkelLine): + res.numRemovedTranslationPlaceholders += 1 + else: + raise ValueError(f"Unexpected diff line: {mdDiffLine}") + print( + f"Updated skeleton file with {res.numAddedLines} added lines " + f"({res.numAddedTranslationPlaceholders} translation placeholders), " + f"{res.numRemovedLines} removed lines ({res.numRemovedTranslationPlaceholders} translation placeholders), " + f"and {res.numUnchangedLines} unchanged lines ({res.numUnchangedTranslationPlaceholders} translation placeholders)", + ) + return res + + +@dataclass +class Result_generateXliff: + numTranslatableStrings: int = 0 + + +def generateXliff( + mdPath: str, + outputPath: str, + skelPath: str | None = None, +) -> Result_generateXliff: + # If a skeleton file is not provided, first generate one + with contextlib.ExitStack() as stack: + if not skelPath: + skelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager( + dir=os.path.dirname(outputPath), + prefix=os.path.basename(mdPath), + suffix=".skel", + ), + ) + generateSkeleton(mdPath=mdPath, outputPath=skelPath) + with open(skelPath, "r", encoding="utf8") as skelFile: + skelContent = skelFile.read() + res = Result_generateXliff() + print( + f"Generating xliff file {prettyPathString(outputPath)} from {prettyPathString(mdPath)} and {prettyPathString(skelPath)}...", + ) + with contextlib.ExitStack() as stack: + mdFile = stack.enter_context(open(mdPath, "r", encoding="utf8")) + outputFile = stack.enter_context( + open(outputPath, "w", encoding="utf8", newline=""), + ) + fileID = os.path.basename(mdPath) + mdUri = getRawGithubURLForPath(mdPath) + print(f"Including Github raw URL: {mdUri}") + outputFile.write( + '\n' + f'\n' + f'\n', + ) + outputFile.write(f"\n{xmlEscape(skelContent)}\n\n") + res.numTranslatableStrings = 0 + for lineNo, (mdLine, skelLine) in enumerate( + zip_longest( + preprocessMarkdownLines(mdFile.readlines()), + skelContent.splitlines(keepends=True), + ), + start=1, + ): + mdLine = mdLine.rstrip() + skelLine = skelLine.rstrip() + if m := re_translationID.match(skelLine): + res.numTranslatableStrings += 1 + prefix, ID, suffix = m.groups() + if prefix and not mdLine.startswith(prefix): + raise ValueError( + f'Line {lineNo}: does not start with "{prefix}", {mdLine=}, {skelLine=}', + ) + if suffix and not mdLine.endswith(suffix): + raise ValueError( + f'Line {lineNo}: does not end with "{suffix}", {mdLine=}, {skelLine=}', + ) + source = mdLine[len(prefix) : len(mdLine) - len(suffix)] + outputFile.write( + f'\n\nline: {lineNo + 1}\n', + ) + if prefix: + outputFile.write( + f'prefix: {xmlEscape(prefix)}\n', + ) + if suffix: + outputFile.write( + f'suffix: {xmlEscape(suffix)}\n', + ) + outputFile.write( + "\n" + f"\n" + f"{xmlEscape(source)}\n" + "\n" + "\n", # fmt: skip + ) + else: + if mdLine != skelLine: + raise ValueError( + f"Line {lineNo}: {mdLine=} does not match {skelLine=}", + ) + outputFile.write("\n") + print( + f"Generated xliff file with {res.numTranslatableStrings} translatable strings", + ) + return res + + +@dataclass +class Result_translateXliff: + numTranslatedStrings: int = 0 + + +def updateXliff( + xliffPath: str, + mdPath: str, + outputPath: str, +): + # uses generateMarkdown, extractSkeleton, updateSkeleton, and generateXliff to generate an updated xliff file. + outputDir = os.path.dirname(outputPath) + print( + f"Generating updated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} and {prettyPathString(mdPath)}...", + ) + with contextlib.ExitStack() as stack: + origMdPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager( + dir=outputDir, + prefix="generated_", + suffix=".md", + ), + ) + generateMarkdown(xliffPath=xliffPath, outputPath=origMdPath, translated=False) + origSkelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager( + dir=outputDir, + prefix="extracted_", + suffix=".skel", + ), + ) + extractSkeleton(xliffPath=xliffPath, outputPath=origSkelPath) + updatedSkelPath = stack.enter_context( + createAndDeleteTempFilePath_contextManager( + dir=outputDir, + prefix="updated_", + suffix=".skel", + ), + ) + updateSkeleton( + origMdPath=origMdPath, + newMdPath=mdPath, + origSkelPath=origSkelPath, + outputPath=updatedSkelPath, + ) + generateXliff( + mdPath=mdPath, + skelPath=updatedSkelPath, + outputPath=outputPath, + ) + print(f"Generated updated xliff file {prettyPathString(outputPath)}") + + +def translateXliff( + xliffPath: str, + lang: str, + pretranslatedMdPath: str, + outputPath: str, + allowBadAnchors: bool = False, +) -> Result_translateXliff: + print( + f"Creating {lang} translated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} using {prettyPathString(pretranslatedMdPath)}...", + ) + res = Result_translateXliff() + with contextlib.ExitStack() as stack: + pretranslatedMdFile = stack.enter_context( + open(pretranslatedMdPath, "r", encoding="utf8"), + ) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + xliffRoot.set("trgLang", lang) + skeletonNode = xliffRoot.find( + "./xliff:file/xliff:skeleton", + namespaces=namespace, + ) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + for lineNo, (skelLine, pretranslatedLine) in enumerate( + zip_longest( + skeletonContent.splitlines(), + preprocessMarkdownLines(pretranslatedMdFile.readlines()), + ), + start=1, + ): + skelLine = skelLine.rstrip() + pretranslatedLine = pretranslatedLine.rstrip() + if m := re_translationID.match(skelLine): + prefix, ID, suffix = m.groups() + if prefix and not pretranslatedLine.startswith(prefix): + raise ValueError( + f'Line {lineNo} of translation does not start with "{prefix}", {pretranslatedLine=}, {skelLine=}', + ) + if suffix and not pretranslatedLine.endswith(suffix): + if allowBadAnchors and (m := re_heading.match(pretranslatedLine)): + print( + f"Warning: ignoring bad anchor in line {lineNo}: {pretranslatedLine}", + ) + suffix = m.group(3) + if suffix and not pretranslatedLine.endswith(suffix): + raise ValueError( + f'Line {lineNo} of translation: does not end with "{suffix}", {pretranslatedLine=}, {skelLine=}', + ) + translation = pretranslatedLine[len(prefix) : len(pretranslatedLine) - len(suffix)] + try: + unit = xliffRoot.find( + f'./xliff:file/xliff:unit[@id="{ID}"]', + namespaces=namespace, + ) + if unit is not None: + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is not None: + target = lxml.etree.Element("target") + target.text = translation + target.tail = "\n" + segment.append(target) + res.numTranslatedStrings += 1 + else: + raise ValueError(f"No segment found for unit {ID}") + else: + raise ValueError(f"Cannot locate Unit {ID} in xliff file") + except Exception as e: + e.add_note(f"Line {lineNo}: {pretranslatedLine=}, {skelLine=}") + raise + elif skelLine != pretranslatedLine: + raise ValueError( + f"Line {lineNo}: pretranslated line {pretranslatedLine!r}, does not match skeleton line {skelLine!r}", + ) + xliff.write(outputPath, encoding="utf8", xml_declaration=True) + print( + f"Translated xliff file with {res.numTranslatedStrings} translated strings", + ) + return res + + +@dataclass +class Result_generateMarkdown: + numTotalLines = 0 + numTranslatableStrings = 0 + numTranslatedStrings = 0 + numBadTranslationStrings = 0 + + +def generateMarkdown( + xliffPath: str, + outputPath: str, + translated: bool = True, +) -> Result_generateMarkdown: + print( + f"Generating markdown file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)}...", + ) + res = Result_generateMarkdown() + with contextlib.ExitStack() as stack: + outputFile = stack.enter_context( + open(outputPath, "w", encoding="utf8", newline=""), + ) + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError("Not an xliff file") + skeletonNode = xliffRoot.find( + "./xliff:file/xliff:skeleton", + namespaces=namespace, + ) + if skeletonNode is None: + raise ValueError("No skeleton found in xliff file") + skeletonContent = skeletonNode.text.strip() + for lineNum, line in enumerate(skeletonContent.splitlines(keepends=True), 1): + res.numTotalLines += 1 + if m := re_translationID.match(line): + prefix, ID, suffix = m.groups() + res.numTranslatableStrings += 1 + unit = xliffRoot.find( + f'./xliff:file/xliff:unit[@id="{ID}"]', + namespaces=namespace, + ) + if unit is None: + raise ValueError(f"Cannot locate Unit {ID} in xliff file") + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + raise ValueError(f"No segment found for unit {ID}") + source = segment.find("./xliff:source", namespaces=namespace) + if source is None: + raise ValueError(f"No source found for unit {ID}") + translation = "" + if translated: + target = segment.find("./xliff:target", namespaces=namespace) + if target is not None: + targetText = target.text + if targetText: + translation = targetText + # Crowdin treats empty targets () as a literal translation. + # Filter out such strings and count them as bad translations. + if translation in ( + "", + "<target/>", + "", + "<target></target>", + ): + res.numBadTranslationStrings += 1 + translation = "" + else: + res.numTranslatedStrings += 1 + # If we have no translation, use the source text + if not translation: + sourceText = source.text + if sourceText is None: + raise ValueError(f"No source text found for unit {ID}") + translation = sourceText + outputFile.write(f"{prefix}{translation}{suffix}\n") + else: + outputFile.write(line) + print( + f"Generated markdown file with {res.numTotalLines} total lines, {res.numTranslatableStrings} translatable strings, and {res.numTranslatedStrings} translated strings. Ignoring {res.numBadTranslationStrings} bad translated strings", + ) + return res + + +def ensureMarkdownFilesMatch(path1: str, path2: str, allowBadAnchors: bool = False): + print( + f"Ensuring files {prettyPathString(path1)} and {prettyPathString(path2)} match...", + ) + with contextlib.ExitStack() as stack: + file1 = stack.enter_context(open(path1, "r", encoding="utf8")) + file2 = stack.enter_context(open(path2, "r", encoding="utf8")) + for lineNo, (line1, line2) in enumerate( + zip_longest( + preprocessMarkdownLines(file1.readlines()), + preprocessMarkdownLines(file2.readlines()), + ), + start=1, + ): + line1 = line1.rstrip() + line2 = line2.rstrip() + if line1 != line2: + if ( + re_postTableHeaderLine.match(line1) + and re_postTableHeaderLine.match(line2) + and line1.count("|") == line2.count("|") + ): + print( + f"Warning: ignoring cell padding of post table header line at line {lineNo}: {line1}, {line2}", + ) + continue + if ( + re_hiddenHeaderRow.match(line1) + and re_hiddenHeaderRow.match(line2) + and line1.count("|") == line2.count("|") + ): + print( + f"Warning: ignoring cell padding of hidden header row at line {lineNo}: {line1}, {line2}", + ) + continue + if allowBadAnchors and (m1 := re_heading.match(line1)) and (m2 := re_heading.match(line2)): + print( + f"Warning: ignoring bad anchor in headings at line {lineNo}: {line1}, {line2}", + ) + line1 = m1.group(1) + m1.group(2) + line2 = m2.group(1) + m2.group(2) + if line1 != line2: + raise ValueError( + f"Files do not match at line {lineNo}: {line1=} {line2=}", + ) + print("Files match") + + +def markdownTranslateCommand(command: str, *args): + print(f"Running markdownTranslate command: {command} {' '.join(args)}") + subprocess.run(["python", __file__, command, *args], check=True) + + +def pretranslateAllPossibleLanguages(langsDir: str, mdBaseName: str): + # This function walks through all language directories in the given directory, skipping en (English) and translates the English xlif and skel file along with the lang's pretranslated md file + enXliffPath = os.path.join(langsDir, "en", f"{mdBaseName}.xliff") + if not os.path.exists(enXliffPath): + raise ValueError(f"English xliff file {enXliffPath} does not exist") + allLangs = set() + succeededLangs = set() + skippedLangs = set() + for langDir in os.listdir(langsDir): + if langDir == "en": + continue + langDirPath = os.path.join(langsDir, langDir) + if not os.path.isdir(langDirPath): + continue + langPretranslatedMdPath = os.path.join(langDirPath, f"{mdBaseName}.md") + if not os.path.exists(langPretranslatedMdPath): + continue + allLangs.add(langDir) + langXliffPath = os.path.join(langDirPath, f"{mdBaseName}.xliff") + if os.path.exists(langXliffPath): + print(f"Skipping {langDir} as the xliff file already exists") + skippedLangs.add(langDir) + continue + try: + translateXliff( + xliffPath=enXliffPath, + lang=langDir, + pretranslatedMdPath=langPretranslatedMdPath, + outputPath=langXliffPath, + allowBadAnchors=True, + ) + except Exception as e: + print(f"Failed to translate {langDir}: {e}") + continue + rebuiltLangMdPath = os.path.join(langDirPath, f"rebuilt_{mdBaseName}.md") + try: + generateMarkdown( + xliffPath=langXliffPath, + outputPath=rebuiltLangMdPath, + ) + except Exception as e: + print(f"Failed to rebuild {langDir} markdown: {e}") + os.remove(langXliffPath) + continue + try: + ensureMarkdownFilesMatch( + rebuiltLangMdPath, + langPretranslatedMdPath, + allowBadAnchors=True, + ) + except Exception as e: + print( + f"Rebuilt {langDir} markdown does not match pretranslated markdown: {e}", + ) + os.remove(langXliffPath) + continue + os.remove(rebuiltLangMdPath) + print(f"Successfully pretranslated {langDir}") + succeededLangs.add(langDir) + if len(skippedLangs) > 0: + print(f"Skipped {len(skippedLangs)} languages already pretranslated.") + print( + f"Pretranslated {len(succeededLangs)} out of {len(allLangs) - len(skippedLangs)} languages.", + ) + + +if __name__ == "__main__": + mainParser = argparse.ArgumentParser() + commandParser = mainParser.add_subparsers( + title="commands", + dest="command", + required=True, + ) + generateXliffParser = commandParser.add_parser("generateXliff") + generateXliffParser.add_argument( + "-m", + "--markdown", + dest="md", + type=str, + required=True, + help="The markdown file to generate the xliff file for", + ) + generateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the xliff file to", + ) + updateXliffParser = commandParser.add_parser("updateXliff") + updateXliffParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The original xliff file", + ) + updateXliffParser.add_argument( + "-m", + "--newMarkdown", + dest="md", + type=str, + required=True, + help="The new markdown file", + ) + updateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the updated xliff to", + ) + translateXliffParser = commandParser.add_parser("translateXliff") + translateXliffParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The xliff file to translate", + ) + translateXliffParser.add_argument( + "-l", + "--lang", + dest="lang", + type=str, + required=True, + help="The language to translate to", + ) + translateXliffParser.add_argument( + "-p", + "--pretranslatedMarkdown", + dest="pretranslatedMd", + type=str, + required=True, + help="The pretranslated markdown file to use", + ) + translateXliffParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the translated xliff file to", + ) + generateMarkdownParser = commandParser.add_parser("generateMarkdown") + generateMarkdownParser.add_argument( + "-x", + "--xliff", + dest="xliff", + type=str, + required=True, + help="The xliff file to generate the markdown file for", + ) + generateMarkdownParser.add_argument( + "-o", + "--output", + dest="output", + type=str, + required=True, + help="The file to output the markdown file to", + ) + generateMarkdownParser.add_argument( + "-u", + "--untranslated", + dest="translated", + action="store_false", + help="Generate the markdown file with the untranslated strings", + ) + ensureMarkdownFilesMatchParser = commandParser.add_parser( + "ensureMarkdownFilesMatch", + ) + ensureMarkdownFilesMatchParser.add_argument( + dest="path1", + type=str, + help="The first markdown file", + ) + ensureMarkdownFilesMatchParser.add_argument( + dest="path2", + type=str, + help="The second markdown file", + ) + pretranslateLangsParser = commandParser.add_parser("pretranslateLangs") + pretranslateLangsParser.add_argument( + "-d", + "--langs-dir", + dest="langsDir", + type=str, + required=True, + help="The directory containing the language directories", + ) + pretranslateLangsParser.add_argument( + "-b", + "--md-base-name", + dest="mdBaseName", + type=str, + required=True, + help="The base name of the markdown files to pretranslate", + ) + args = mainParser.parse_args() + match args.command: + case "generateXliff": + generateXliff(mdPath=args.md, outputPath=args.output) + case "updateXliff": + updateXliff( + xliffPath=args.xliff, + mdPath=args.md, + outputPath=args.output, + ) + case "generateMarkdown": + generateMarkdown( + xliffPath=args.xliff, + outputPath=args.output, + translated=args.translated, + ) + case "translateXliff": + translateXliff( + xliffPath=args.xliff, + lang=args.lang, + pretranslatedMdPath=args.pretranslatedMd, + outputPath=args.output, + ) + case "pretranslateLangs": + pretranslateAllPossibleLanguages( + langsDir=args.langsDir, + mdBaseName=args.mdBaseName, + ) + case "ensureMarkdownFilesMatch": + ensureMarkdownFilesMatch(path1=args.path1, path2=args.path2) + case _: + raise ValueError(f"Unknown command: {args.command}") From 00cf8cf29b6df42fa20ea124eda32ea68963703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 22:28:56 +0200 Subject: [PATCH 084/142] Update markdownTranslate --- .github/scripts/markdownTranslate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py index e276049..d9cbaf5 100644 --- a/.github/scripts/markdownTranslate.py +++ b/.github/scripts/markdownTranslate.py @@ -3,7 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from typing import Generator +from collections.abc import Generator from collections.abc import Iterable import tempfile import os From 480c70e5f087a5f1594c3ad2b089674495c2a7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 03:41:06 +0200 Subject: [PATCH 085/142] Exclude markdownTranslate from pyright, it contains many errors --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 544accf..7791208 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ exclude = [ # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. + ".github/scripts/markdownTranslate.py", ] # Tell pyright where to load python code from From ce2d2da38286f1c15b68548387c1acbdf99d4c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 06:58:05 +0200 Subject: [PATCH 086/142] Update source files --- .github/scripts/crowdinSync.ps1 | 56 ++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 418d97a..9b6a719 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -12,6 +12,7 @@ if (-not $addonId) { exit 1 } +# Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" Write-Host "Processing language: $($dir.Name)" @@ -77,21 +78,46 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { } } # End foreach - # COMMIT CHANGES - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" +# COMMIT CHANGES +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" - git add addon/locale addon/doc +git add addon/locale addon/doc - git diff --staged --quiet - if ($LASTEXITCODE -ne 0) { - git commit -m "Update translations for $addonId from Crowdin" - git switch $env:downloadTranslationsBranch 2>$null +git diff --staged --quiet +if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch $env:downloadTranslationsBranch 2>$null - if ($LASTEXITCODE -ne 0) { - git switch -c $env:downloadTranslationsBranch - } - git push -f --set-upstream origin $env:downloadTranslationsBranch - } else { - Write-Host "Nothing to commit." - } + if ($LASTEXITCODE -ne 0) { + git switch -c $env:downloadTranslationsBranch + } + git push -f --set-upstream origin $env:downloadTranslationsBranch +} else { + Write-Host "Nothing to commit." +} + +# Update xliff +$xlifFile = "$addonId.xliff" +$mdFile = "./readme.md" +if ((Test-Path $xlifFile) -and (Test-Path $mdFile)) { + $tempXliff = [System.IO.Path]::GetTempFileName() + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + Write-Host "Updated $xlifFile based on $mdFile" + Write-Host "Uploading updated XLIFF to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$xlifFile" -c addon +} else { + Write-Host "Documentation files not found, skipping xliff update." +} + +# Update pot file +scons pot +$potFile = "$addonId.pot" +if (Test-Path $potFile) { + Write-Host "Uploading updated POT to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$potFile" -c addon +} else { + Write-Host "POT file not found, skipping POT update." +} From 3981eceb009cc4154b082b30c17e965f74bd4d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 08:53:48 +0200 Subject: [PATCH 087/142] Updates --- .github/scripts/crowdinSync.ps1 | 89 +++++++++++++++++++------------ .github/workflows/crowdinL10n.yml | 7 ++- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 9b6a719..6e86ec3 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,17 +1,67 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' +# Config git +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" -write-host "Exporting translations from Crowdin..." -./l10nUtil.exe exportTranslations -o _addonL10n -c addon - -New-Item -ItemType Directory -Force -Path addon/locale | Out-Null -New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $addonId = $env:ADDON_ID.Trim() if (-not $addonId) { Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." exit 1 } +# Update xliff file +$xliffFile = "./$addonId.xliff" +$mdFile = "./readme.md" +if (Test-Path $mdFile) { + if (Test-Path $xliffFile) { + $tempXliff = [System.IO.Path]::GetTempFileName() + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + Write-Host "Updated $xliffFile based on $mdFile" + } else { + Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." + uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile + } +} else { + Write-Host "readme.md not found. Skipping XLIFF generation." +} + +# Update pot file in Crowdin +uv run scons pot +$potFile = "$addonId.pot" +if (Test-Path $potFile) { + Write-Host "Uploading updated POT to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$potFile" -c addon +} else { + Write-Host "POT file not found, skipping POT update." +} + +# Update xliff file in Crowdin +if (Test-Path $xliffFile) { + Write-Host "Uploading XLIFF to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon + git add "$xliffFile" + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update $xliffFile for $addonId" + git push + } else { + Write-Host "No changes to $xliffFile, skipping commit." + } +} else { + Write-Host "XLIFF file not found, skipping XLIFF upload." +} + +# Export translations +write-host "Exporting translations from Crowdin..." +./l10nUtil.exe exportTranslations -o _addonL10n -c addon + +New-Item -ItemType Directory -Force -Path addon/locale | Out-Null +New-Item -ItemType Directory -Force -Path addon/doc | Out-Null + + # Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" @@ -79,11 +129,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { } # End foreach # COMMIT CHANGES -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" - git add addon/locale addon/doc - git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" @@ -96,28 +142,3 @@ if ($LASTEXITCODE -ne 0) { } else { Write-Host "Nothing to commit." } - -# Update xliff -$xlifFile = "$addonId.xliff" -$mdFile = "./readme.md" -if ((Test-Path $xlifFile) -and (Test-Path $mdFile)) { - $tempXliff = [System.IO.Path]::GetTempFileName() - Copy-Item "$addonId.xliff" $tempXliff -Force - Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile - Write-Host "Updated $xlifFile based on $mdFile" - Write-Host "Uploading updated XLIFF to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$xlifFile" -c addon -} else { - Write-Host "Documentation files not found, skipping xliff update." -} - -# Update pot file -scons pot -$potFile = "$addonId.pot" -if (Test-Path $potFile) { - Write-Host "Uploading updated POT to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$potFile" -c addon -} else { - Write-Host "POT file not found, skipping POT update." -} diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 3fd37d8..82a3bd8 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -37,7 +37,12 @@ jobs: - name: Install dependencies run: uv sync - + - name: Install gettext + run: | + choco install -y gettext + # Add gettext to PATH for current and future steps + $gettextPath = "C:\Program Files\gettext-iconv\bin" + echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Get add-on info id: getAddonInfo shell: pwsh From 0def05dbbfc39a4b2c8fd5f468a195e3615a0b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 09:50:16 +0200 Subject: [PATCH 088/142] Fix files with pre-commit --- .github/scripts/crowdinSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 6e86ec3..53b3e62 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -18,7 +18,7 @@ if (Test-Path $mdFile) { $tempXliff = [System.IO.Path]::GetTempFileName() Copy-Item "$addonId.xliff" $tempXliff -Force Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile Write-Host "Updated $xliffFile based on $mdFile" } else { Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." From 1c92aab38c5f15ada8afd09c1d43c4e9ffbd4428 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 26 Apr 2026 13:36:06 +0200 Subject: [PATCH 089/142] refactor: use Crowdin API for translation checks and optimize file selection - Replace langid and polib dependencies with direct Crowdin API calls. - Implement smart mapping between local filenames and Crowdin sources. - Add support for comparing .md and .xliff documentation files. - Prioritize XLIFF format and select the file with the highest translation ratio. - Standardize script output for poScore, mdScore, and translationRatio. --- .github/scripts/checkTranslation.py | 150 +++++++++++++--------------- .github/scripts/crowdinSync.ps1 | 135 ++++++++++++------------- .github/workflows/crowdinL10n.yml | 8 +- pyproject.toml | 1 - 4 files changed, 139 insertions(+), 155 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 95262b7..f13b177 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -1,98 +1,90 @@ import sys import os -import xml.etree.ElementTree as ET -import polib - - -def normalize(s: str | None) -> str: - return " ".join((s or "").strip().lower().split()) - +from crowdin_api import CrowdinClient # ----------------------------- -# PO CHECK +# CROWDIN API SCORE # ----------------------------- - -def checkPo(path: str) -> float: - po = polib.pofile(path) - translated = 0 - total = 0 - - for entry in po: - if not entry.msgid.strip(): - continue - - total += 1 - - if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): - translated += 1 - - return translated / total if total else 0.0 - +def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: + """ + Fetches the translation progress percentage directly from Crowdin API. + Returns a float between 0.0 and 1.0. + """ + token = os.environ.get("crowdinAuthToken") + project_id_env = os.environ.get("CROWDIN_PROJECT_ID") + + # Ensure credentials are present + if not token or not project_id_env: + return 0.0 + + client = CrowdinClient(token=token) + project_id = int(project_id_env) + + try: + # 1. NORMALIZE SEARCH TERMS + # Extract base name (e.g., 'askOpenRouter') and extension + base_target = crowdin_file_name.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() + ext_target = crowdin_file_name.split('.')[-1].lower() + + # Mapping: if we check a .po, we look for a .pot on Crowdin + search_ext = ".pot" if ext_target == "po" else f".{ext_target}" + + # 2. FETCH ALL FILES TO FIND MATCHING ID + files = client.source_files.list_files(projectId=project_id, limit=500) + file_id = None + + for f in files['data']: + path_crowdin = f['data']['path'].lower() + if path_crowdin.endswith(f"{base_target}{search_ext}"): + file_id = f['data']['id'] + break + + if file_id is None: + return 0.0 + + # 3. FETCH PROGRESS FOR THE SPECIFIC FILE + # We use get_file_progress which is reliable for specific file IDs + progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) + + for item in progress['data']: + if item['data']['languageId'].lower() == lang_id.lower(): + # Return ratio (0.0 to 1.0) + return float(item['data']['translationProgress']) / 100 + + except Exception: + # Fallback to 0.0 in case of API or network error + return 0.0 + + return 0.0 # ----------------------------- -# XLIFF CHECK +# MAIN ENGINE # ----------------------------- - -def checkXliff(path: str) -> float: - tree = ET.parse(path) - root = tree.getroot() - translated = 0 - total = 0 - source = None - - for elem in root.iter(): - if elem.tag.endswith("source"): - source = normalize(elem.text) - - elif elem.tag.endswith("target"): - target = normalize(elem.text) - - if source: - total += 1 - if target and target != source: - translated += 1 - - return translated / total if total else 0.0 - - def main(): - if len(sys.argv) < 2: - print("Usage:") - print(" checkTranslation.py ") + """ + Main entry point. + Expects two arguments: + """ + if len(sys.argv) < 3: sys.exit(2) - args = sys.argv[1:] - - path = args[0] - - if not os.path.exists(path): - print(f"File not found: {path}") - sys.exit(2) - - ext = os.path.splitext(path)[1].lower() - - # ------------------------- - # PO - # ------------------------- - if ext == ".po": - ratio = checkPo(path) - print(f"translation_ratio={ratio}") - sys.exit(0 if ratio > 0.05 else 1) - - # ------------------------- - # XLIFF - # ------------------------- - elif ext in [".xliff", ".xlf"]: - ratio = checkXliff(path) - print(f"translation_ratio={ratio}") - sys.exit(0 if ratio > 0.05 else 1) + file_name = sys.argv[1] + lang = sys.argv[2] + # All evaluations now go through the Crowdin API + score = get_score_from_api(lang, file_name) + + # Output formatting for PowerShell capture + print(f"translationRatio={score}") + if file_name.lower().endswith('.md'): + print(f"mdScore={score}") else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + print(f"poScore={score}") + # Exit with code 0 if score > 5%, otherwise 1 + sys.exit(0 if score > 0.05 else 1) if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 53b3e62..59d6c65 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,15 +1,10 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' + # Config git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" -$addonId = $env:ADDON_ID.Trim() -if (-not $addonId) { - Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." - exit 1 -} - # Update xliff file $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" @@ -55,90 +50,92 @@ if (Test-Path $xliffFile) { } # Export translations -write-host "Exporting translations from Crowdin..." +Write-Host "Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon +# Ensure base directories exist New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID." + exit 1 +} -# Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - Write-Host "==============================" - Write-Host "Processing language: $($dir.Name)" - Write-Host "==============================" - $langCode = $dir.Name + $langShort = $langCode.Split('_')[0] - # Paths - $poFile = Join-Path $dir.FullName "$addonId.po" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + if ($langCode -eq "en") { continue } - $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $targetDocDir = "addon/doc/$langCode" - $mdFile = Join-Path $targetDocDir "readme.md" + Write-Host "--- Processing: $addonId ($langCode) ---" - # ---------------------------- - # SKIP ENGLISH (source language) - # ---------------------------- - if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no XLIFF processing required" - continue - } + # Temporary files from Crowdin + $remoteMd = Join-Path $dir.FullName "$addonId.md" + $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" + $remotePo = Join-Path $dir.FullName "$addonId.po" - # ---------------------------- - # PO PROCESSING - # ---------------------------- - if (Test-Path $poFile) { - Write-Host "Checking PO file..." - uv run ./.github/scripts/checkTranslation.py "$poFile" - $isPoTranslated = ($LASTEXITCODE -eq 0) - Write-Host "PO translated: $isPoTranslated" - if ($isPoTranslated) { - Write-Host "Updating local PO" + # Local paths + $localMdDir = "addon/doc/$langCode" + $localMd = "$localMdDir/readme.md" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + # 1. PO PROCESSING + if (Test-Path $remotePo) { + uv run python .github/scripts/checkTranslation.py "$addonId.po" $langShort + if ($LASTEXITCODE -eq 0) { New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null - Move-Item $poFile $localPoPath -Force - } else { - Write-Host "PO not translated" - if (Test-Path $localPoPath) { - Write-Host "Uploading local PO to Crowdin" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon - } else { - Write-Host "No local PO available" - } + Move-Item $remotePo $localPoPath -Force } } - # ---------------------------- - # XLIFF PROCESSING - # ---------------------------- - if (Test-Path $xliffFile) { - Write-Host "Checking XLIFF..." - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) - Write-Host "XLIFF translated: $isXliffTranslated" - if ($isXliffTranslated) { - Write-Host "Converting XLIFF → MD" - ./l10nUtil.exe xliff2md $xliffFile $mdFile + # 2. EVALUATION VIA API + $scoreMd = 0.0 + $scoreXliff = 0.0 + + if (Test-Path $remoteMd) { + $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $langShort + $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] + } + + if (Test-Path $remoteXliff) { + $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $langShort + $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] + } + + Write-Host "Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + + # 3. DECISION LOGIC + $threshold = 0.5 + $imported = $false + + if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { + # Create doc directory if needed + if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } + + if ($scoreXliff -ge $scoreMd) { + Write-Host "Action: Converting XLIFF to local MD" + ./l10nUtil.exe xliff2md $remoteXliff $localMd + $imported = $true } else { - Write-Host "XLIFF not translated" + Write-Host "Action: Importing Remote MD to local" + Move-Item $remoteMd $localMd -Force + $imported = $true } - } else { - Write-Host "No XLIFF file found" } -} # End foreach -# COMMIT CHANGES + # 4. FALLBACK: Upload local if remote is poor + if (-not $imported -and (Test-Path $localMd)) { + Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } +} + git add addon/locale addon/doc git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" - git switch $env:downloadTranslationsBranch 2>$null - - if ($LASTEXITCODE -ne 0) { - git switch -c $env:downloadTranslationsBranch - } - git push -f --set-upstream origin $env:downloadTranslationsBranch -} else { - Write-Host "Nothing to commit." -} + $branch = $env:downloadTranslationsBranch + git push -f origin "HEAD:$branch" +} \ No newline at end of file diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 82a3bd8..0615e63 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -14,6 +14,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} + CROWDIN_PROJECT_ID: 780748 jobs: crowdinSync: @@ -37,12 +38,7 @@ jobs: - name: Install dependencies run: uv sync - - name: Install gettext - run: | - choco install -y gettext - # Add gettext to PATH for current and future steps - $gettextPath = "C:\Program Files\gettext-iconv\bin" - echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Get add-on info id: getAddonInfo shell: pwsh diff --git a/pyproject.toml b/pyproject.toml index 7791208..ee2c588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - "polib==1.2.0", # Lint "uv==0.11.6", "ruff==0.14.5", From c5e4076bb57cd095b45f4bb8a818e0e5e120f2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:32:22 +0200 Subject: [PATCH 090/142] Add crowdin api client --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ee2c588..d5fe2f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management + "crowdin-api-client==1.24.1", "nh3==0.3.2", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", From c0983924e0ab0b6ed38b0a475b911eab73017a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:34:27 +0200 Subject: [PATCH 091/142] Add dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d5fe2f1..55e4001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,9 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management - "crowdin-api-client==1.24.1", + "requests==2.33.0", "nh3==0.3.2", + "crowdin-api-client==1.24.1", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", From 39416a0ddfe7a82176950617170f23832dc892bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:47:35 +0200 Subject: [PATCH 092/142] Remove limit for list of files, fetch all --- .github/scripts/checkTranslation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index f13b177..c244595 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -31,7 +31,7 @@ def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: search_ext = ".pot" if ext_target == "po" else f".{ext_target}" # 2. FETCH ALL FILES TO FIND MATCHING ID - files = client.source_files.list_files(projectId=project_id, limit=500) + files = client.source_files.with_fetch_all().list_files(project_id) file_id = None for f in files['data']: From 23968642626848a60c91a90202a94105ceddbf28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:57:22 +0200 Subject: [PATCH 093/142] Improvements --- .github/scripts/checkTranslation.py | 40 ++++---- .github/scripts/crowdinSync.ps1 | 2 +- pyproject.toml | 1 + uv.lock | 138 +++++++++++++++++++++++++--- 4 files changed, 151 insertions(+), 30 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index c244595..7ca92cd 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -6,6 +6,7 @@ # CROWDIN API SCORE # ----------------------------- + def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: """ Fetches the translation progress percentage directly from Crowdin API. @@ -24,47 +25,49 @@ def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: try: # 1. NORMALIZE SEARCH TERMS # Extract base name (e.g., 'askOpenRouter') and extension - base_target = crowdin_file_name.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() - ext_target = crowdin_file_name.split('.')[-1].lower() - + base_target = crowdin_file_name.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() + ext_target = crowdin_file_name.split(".")[-1].lower() + # Mapping: if we check a .po, we look for a .pot on Crowdin search_ext = ".pot" if ext_target == "po" else f".{ext_target}" - + # 2. FETCH ALL FILES TO FIND MATCHING ID files = client.source_files.with_fetch_all().list_files(project_id) file_id = None - - for f in files['data']: - path_crowdin = f['data']['path'].lower() + + for f in files["data"]: + path_crowdin = f["data"]["path"].lower() if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f['data']['id'] + file_id = f["data"]["id"] break - + if file_id is None: return 0.0 # 3. FETCH PROGRESS FOR THE SPECIFIC FILE # We use get_file_progress which is reliable for specific file IDs progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) - - for item in progress['data']: - if item['data']['languageId'].lower() == lang_id.lower(): + + for item in progress["data"]: + if item["data"]["languageId"].lower() == lang_id.lower(): # Return ratio (0.0 to 1.0) - return float(item['data']['translationProgress']) / 100 - + return float(item["data"]["translationProgress"]) / 100 + except Exception: # Fallback to 0.0 in case of API or network error return 0.0 - + return 0.0 + # ----------------------------- # MAIN ENGINE # ----------------------------- + def main(): """ - Main entry point. + Main entry point. Expects two arguments: """ if len(sys.argv) < 3: @@ -75,10 +78,10 @@ def main(): # All evaluations now go through the Crowdin API score = get_score_from_api(lang, file_name) - + # Output formatting for PowerShell capture print(f"translationRatio={score}") - if file_name.lower().endswith('.md'): + if file_name.lower().endswith(".md"): print(f"mdScore={score}") else: print(f"poScore={score}") @@ -86,5 +89,6 @@ def main(): # Exit with code 0 if score > 5%, otherwise 1 sys.exit(0 if score > 0.05 else 1) + if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 59d6c65..2f98fea 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -138,4 +138,4 @@ if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" $branch = $env:downloadTranslationsBranch git push -f origin "HEAD:$branch" -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index 55e4001..9fe5dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ exclude = [ # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. ".github/scripts/markdownTranslate.py", + ".github/scripts/checkTranslation.py", ] # Tell pyright where to load python code from diff --git a/uv.lock b/uv.lock index 4bfb01f..1969d46 100644 --- a/uv.lock +++ b/uv.lock @@ -6,15 +6,16 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ + { name = "crowdin-api-client" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, { name = "mdx-gh-links" }, { name = "mdx-truly-sane-lists" }, { name = "nh3" }, - { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, + { name = "requests" }, { name = "ruff" }, { name = "scons" }, { name = "uv" }, @@ -22,20 +23,30 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, - { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "requests", specifier = "==2.33.0" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, { name = "uv", specifier = "==0.11.6" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -45,6 +56,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -72,6 +133,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + [[package]] name = "lxml" version = "6.1.0" @@ -200,15 +270,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] -[[package]] -name = "polib" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, -] - [[package]] name = "pre-commit" version = "4.2.0" @@ -274,6 +335,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + [[package]] name = "ruff" version = "0.14.5" @@ -318,6 +394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uv" version = "0.11.6" @@ -358,3 +443,34 @@ sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133 wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] From 94929e793dcd728da22fd396c0cf60a9809387f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 17:28:37 +0200 Subject: [PATCH 094/142] Updates --- .github/scripts/crowdinSync.ps1 | 12 ++++++------ .github/workflows/crowdinL10n.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 2f98fea..bfff2c9 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -5,6 +5,12 @@ $ErrorActionPreference = 'Stop' git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID." + exit 1 +} + # Update xliff file $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" @@ -57,12 +63,6 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -$addonId = $env:ADDON_ID.Trim() -if (-not $addonId) { - Write-Error "Failed to get addon ID." - exit 1 -} - foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { $langCode = $dir.Name $langShort = $langCode.Split('_')[0] diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 0615e63..8ec5efc 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -27,27 +27,27 @@ jobs: uses: actions/checkout@v6 with: submodules: true - - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install uv uses: astral-sh/setup-uv@v7 - - name: Install dependencies run: uv sync - + - name: Install gettext + run: | + choco install -y gettext + # Add gettext to PATH for current and future steps + $gettextPath = "C:\Program Files\gettext-iconv\bin" + echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Get add-on info id: getAddonInfo shell: pwsh run: uv run ./.github/scripts/setOutputs.py - - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - - name: Download translations from Crowdin shell: pwsh env: From 57d46f550b1ba6ba50d20aed74464179eaf8340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 27 Apr 2026 18:45:30 +0200 Subject: [PATCH 095/142] Add language mappings --- .github/scripts/crowdinSync.ps1 | 10 ++++++---- .github/scripts/languageMappings.json | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/languageMappings.json diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index bfff2c9..873d45f 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -63,12 +63,14 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +$languageMappings = Get-Content".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name - $langShort = $langCode.Split('_')[0] + $langCode = $dir.Name if ($langCode -eq "en") { continue } - + $crowdinLang = $languageMappings[$langCode] + if (-not $crowdinLang) { $crowdinLang = $langCode } + $langShort = $langCode.Split('_')[0] Write-Host "--- Processing: $addonId ($langCode) ---" # Temporary files from Crowdin @@ -128,7 +130,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { # 4. FALLBACK: Upload local if remote is poor if (-not $imported -and (Test-Path $localMd)) { Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c addon } } diff --git a/.github/scripts/languageMappings.json b/.github/scripts/languageMappings.json new file mode 100644 index 0000000..cae6e13 --- /dev/null +++ b/.github/scripts/languageMappings.json @@ -0,0 +1,14 @@ +{ + "af_ZA": "af", + "de_CH": "de-CH", + "es": "es-ES", + "es_CO": "es-CO", + "nb_NO": "nb", + "nn_NO": "nn-NO", + "pt_PT": "pt-PT", + "pt_BR": "pt-BR", + "sr": "sr-CS", + "zh_CN": "zh-CN", + "zh_HK": "zh-HK", + "zh_TW": "zh-TW" +} From 74388d089ccbc39e816228b33056bd547eb752fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 27 Apr 2026 19:34:59 +0200 Subject: [PATCH 096/142] Fix --- .github/scripts/crowdinSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 873d45f..0237ef1 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -63,7 +63,7 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -$languageMappings = Get-Content".github/scripts/languageMappings.json" | ConvertFrom-Json +$languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { $langCode = $dir.Name From 1f8eb842bd7a7d349892832d77b275e8544b4079 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 07:44:30 +0200 Subject: [PATCH 097/142] refactor: modernize translation workflow and integrate language mappings This commit significantly improves the synchronization process between Crowdin and the local repository, with a primary focus on the integration of the 'languageMappings.json' file originally implemented by @nvdaes. Key changes include: - Integrated languageMappings.json: Now used as the source of truth for Crowdin-specific language identifiers during the upload process. - Added langCodes.py: Implemented a symmetrical Python helper to map Crowdin export codes back to standard NVDA directory names (e.g., handling 'es-ES' -> 'es', 'ar-SA' -> 'ar_SA', 'sr-CS' -> 'sr'). - Fixed API Pagination: Updated checkTranslation.py to handle recursive API calls. This ensures all languages (including those beyond the initial 25 results, such as Turkish) are correctly processed and scored. - Enhanced crowdinSync.ps1: * Implemented bi-directional synchronization to upload local legacy files (.po and .md) when remote translation quality is low. * Added comprehensive debug and success logging for documentation conversions (XLIFF/MD) and scoring. * Improved decision logic for readme.md updates based on score comparison. Special thanks to @nvdaes for the mapping implementation. --- .github/scripts/checkTranslation.py | 133 +++++++++++++++++----------- .github/scripts/crowdinSync.ps1 | 107 +++++++++++++--------- .github/scripts/langCodes.py | 60 +++++++++++++ 3 files changed, 207 insertions(+), 93 deletions(-) create mode 100644 .github/scripts/langCodes.py diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 7ca92cd..b067d6d 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,93 +2,124 @@ import os from crowdin_api import CrowdinClient -# ----------------------------- -# CROWDIN API SCORE -# ----------------------------- +def find_file_id(client, project_id, base_target, search_ext): + """ + Iterates through all project files (using pagination) to find the ID + of the source file matching the target name and extension. + """ + offset = 0 + limit = 100 + + while True: + resp = client.source_files.list_files( + projectId=project_id, + limit=limit, + offset=offset + ) + + data = resp['data'] + for f in data: + path_crowdin = f['data']['path'].lower() + # Check if the path ends with addon_id.pot or addon_id.xliff + if path_crowdin.endswith(f"{base_target}{search_ext}"): + file_id = f['data']['id'] + print(f"DEBUG: Match found: {path_crowdin} (ID: {file_id})") + return file_id + + if len(data) < limit: + break + + offset += limit + return None -def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: +def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: """ - Fetches the translation progress percentage directly from Crowdin API. - Returns a float between 0.0 and 1.0. + Retrieves the translation progress score for a specific language and file. + Handles pagination for both file listing and language status. """ token = os.environ.get("crowdinAuthToken") - project_id_env = os.environ.get("CROWDIN_PROJECT_ID") + p_id_env = os.environ.get("CROWDIN_PROJECT_ID") - # Ensure credentials are present - if not token or not project_id_env: + if not token or not p_id_env: + print("ERROR: Missing environment variables 'crowdinAuthToken' or 'CROWDIN_PROJECT_ID'.") return 0.0 client = CrowdinClient(token=token) - project_id = int(project_id_env) + p_id = int(p_id_env) try: - # 1. NORMALIZE SEARCH TERMS - # Extract base name (e.g., 'askOpenRouter') and extension - base_target = crowdin_file_name.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() - ext_target = crowdin_file_name.split(".")[-1].lower() + # Clean and prepare search patterns + # Example: 'addon/locale/fr/LC_MESSAGES/myaddon.po' -> base_target: 'myaddon' + base_target = file_name_to_search.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() + ext_target = file_name_to_search.split('.')[-1].lower() - # Mapping: if we check a .po, we look for a .pot on Crowdin + # On Crowdin, the source for a .po file is usually a .pot file search_ext = ".pot" if ext_target == "po" else f".{ext_target}" - # 2. FETCH ALL FILES TO FIND MATCHING ID - files = client.source_files.with_fetch_all().list_files(project_id) - file_id = None + print(f"DEBUG: Searching for source file: {base_target}{search_ext}") - for f in files["data"]: - path_crowdin = f["data"]["path"].lower() - if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f["data"]["id"] - break + file_id = find_file_id(client, p_id, base_target, search_ext) if file_id is None: + print(f"WARNING: File '{base_target}{search_ext}' not found on Crowdin.") return 0.0 - # 3. FETCH PROGRESS FOR THE SPECIFIC FILE - # We use get_file_progress which is reliable for specific file IDs - progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) - - for item in progress["data"]: - if item["data"]["languageId"].lower() == lang_id.lower(): - # Return ratio (0.0 to 1.0) - return float(item["data"]["translationProgress"]) / 100 + # Pagination for translation status (Progress) + offset = 0 + limit = 100 + + while True: + resp = client.translation_status.get_file_progress( + projectId=p_id, + fileId=file_id, + limit=limit, + offset=offset + ) + + data = resp['data'] + for item in data: + lang_api = item['data']['languageId'] + + # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API) + # Also handles underscore to dash conversion for Crowdin compatibility + if lang_api.lower().startswith(lang_id.lower().replace('_', '-')): + progress = float(item['data']['translationProgress']) + return progress / 100 + + # Check pagination total + total = resp['pagination']['totalCount'] + if offset + limit >= total: + break + offset += limit - except Exception: - # Fallback to 0.0 in case of API or network error + print(f"DEBUG: Language '{lang_id}' not found in progress list for this file.") return 0.0 - return 0.0 - - -# ----------------------------- -# MAIN ENGINE -# ----------------------------- - + except Exception as e: + print(f"API ERROR: {e}") + return 0.0 def main(): - """ - Main entry point. - Expects two arguments: - """ if len(sys.argv) < 3: + print("Usage: python checkTranslation.py ") sys.exit(2) - file_name = sys.argv[1] + input_file = sys.argv[1] lang = sys.argv[2] - # All evaluations now go through the Crowdin API - score = get_score_from_api(lang, file_name) + score = get_score_from_api(input_file, lang) - # Output formatting for PowerShell capture + # Output formatted for capture by the PowerShell script (crowdinSync.ps1) print(f"translationRatio={score}") - if file_name.lower().endswith(".md"): + + if input_file.lower().endswith('.md'): print(f"mdScore={score}") else: print(f"poScore={score}") - # Exit with code 0 if score > 5%, otherwise 1 + # Exit with success (0) if there is at least some translated content sys.exit(0 if score > 0.05 else 1) - if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 0237ef1..a96ae3e 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,7 +1,7 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' -# Config git +# Git configuration for automated commits git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -11,133 +11,156 @@ if (-not $addonId) { exit 1 } -# Update xliff file +# --- STEP 1: PREPARATION AND SOURCE UPDATE --- + $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" + if (Test-Path $mdFile) { if (Test-Path $xliffFile) { $tempXliff = [System.IO.Path]::GetTempFileName() Copy-Item "$addonId.xliff" $tempXliff -Force - Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + Write-Host "DEBUG: Updating XLIFF source based on readme.md..." uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile - Write-Host "Updated $xliffFile based on $mdFile" } else { - Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." + Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile } -} else { - Write-Host "readme.md not found. Skipping XLIFF generation." } -# Update pot file in Crowdin +# Update POT file (addon interface) uv run scons pot $potFile = "$addonId.pot" + +# --- STEP 2: UPLOAD SOURCES TO CROWDIN --- + if (Test-Path $potFile) { - Write-Host "Uploading updated POT to Crowdin..." + Write-Host "DEBUG: Uploading updated POT source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$potFile" -c addon -} else { - Write-Host "POT file not found, skipping POT update." } -# Update xliff file in Crowdin if (Test-Path $xliffFile) { - Write-Host "Uploading XLIFF to Crowdin..." + Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon git add "$xliffFile" git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update $xliffFile for $addonId" git push - } else { - Write-Host "No changes to $xliffFile, skipping commit." } -} else { - Write-Host "XLIFF file not found, skipping XLIFF upload." } -# Export translations -Write-Host "Exporting translations from Crowdin..." +# --- STEP 3: EXPORT AND PROCESS TRANSLATIONS --- + +Write-Host "DEBUG: Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon # Ensure base directories exist New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +# Load language mappings for Crowdin API calls $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json -foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name +foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + $langCode = $dir.Name + if ($langCode -eq "en") { continue } + + # Identify codes $crowdinLang = $languageMappings[$langCode] if (-not $crowdinLang) { $crowdinLang = $langCode } - $langShort = $langCode.Split('_')[0] - Write-Host "--- Processing: $addonId ($langCode) ---" + $langShort = $langCode.Split('-')[0].Split('_')[0] - # Temporary files from Crowdin + # Map to local NVDA directory + $localLangDir = uv run python .github/scripts/langCodes.py $langCode + + Write-Host "`n--- Processing Language: $langCode (Mapped to local: $localLangDir) ---" + + # Paths $remoteMd = Join-Path $dir.FullName "$addonId.md" $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" $remotePo = Join-Path $dir.FullName "$addonId.po" - - # Local paths - $localMdDir = "addon/doc/$langCode" + $localMdDir = "addon/doc/$localLangDir" $localMd = "$localMdDir/readme.md" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + $localPoPath = "addon/locale/$localLangDir/LC_MESSAGES/nvda.po" - # 1. PO PROCESSING + # --- 3.1 PO FILE PROCESSING --- + $poImported = $false if (Test-Path $remotePo) { + Write-Host "DEBUG: Checking Remote PO progress for $langShort..." uv run python .github/scripts/checkTranslation.py "$addonId.po" $langShort if ($LASTEXITCODE -eq 0) { + Write-Host "SUCCESS: Remote PO is valid. Importing to $localPoPath" New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null Move-Item $remotePo $localPoPath -Force + $poImported = $true + } else { + Write-Host "WARNING: Remote PO progress is below threshold." } } - # 2. EVALUATION VIA API + if (-not $poImported -and (Test-Path $localPoPath)) { + Write-Host "ACTION: Uploading local legacy PO to Crowdin ($crowdinLang) as fallback." + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.po" $localPoPath -c addon + } + + # --- 3.2 DOCUMENTATION PROCESSING (MD & XLIFF) --- $scoreMd = 0.0 $scoreXliff = 0.0 if (Test-Path $remoteMd) { + Write-Host "DEBUG: Evaluating Remote Markdown score..." $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $langShort $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] + } else { + Write-Host "DEBUG: No remote Markdown file found for this language." } if (Test-Path $remoteXliff) { + Write-Host "DEBUG: Evaluating Remote XLIFF score..." $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $langShort $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] + } else { + Write-Host "DEBUG: No remote XLIFF file found for this language." } - Write-Host "Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + Write-Host "DEBUG: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff" - # 3. DECISION LOGIC $threshold = 0.5 - $imported = $false + $docImported = $false if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { - # Create doc directory if needed if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } if ($scoreXliff -ge $scoreMd) { - Write-Host "Action: Converting XLIFF to local MD" + Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($localLangDir)..." ./l10nUtil.exe xliff2md $remoteXliff $localMd - $imported = $true + $docImported = $true } else { - Write-Host "Action: Importing Remote MD to local" + Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($localLangDir)..." Move-Item $remoteMd $localMd -Force - $imported = $true + $docImported = $true } + } else { + Write-Host "WARNING: Both remote MD and XLIFF scores are below threshold ($threshold)." } - # 4. FALLBACK: Upload local if remote is poor - if (-not $imported -and (Test-Path $localMd)) { - Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." + if (-not $docImported -and (Test-Path $localMd)) { + Write-Host "ACTION: Documentation quality too low. Uploading local MD to Crowdin ($crowdinLang) as fallback." ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c addon } } +# --- STEP 4: COMMIT UPDATED TRANSLATIONS --- + git add addon/locale addon/doc git diff --staged --quiet if ($LASTEXITCODE -ne 0) { - git commit -m "Update translations for $addonId from Crowdin" + git commit -m "Update translations for $addonId from Crowdin (Automatic Sync)" $branch = $env:downloadTranslationsBranch git push -f origin "HEAD:$branch" + Write-Host "SUCCESS: Translations committed and pushed." +} else { + Write-Host "DEBUG: No changes in translations to commit." } diff --git a/.github/scripts/langCodes.py b/.github/scripts/langCodes.py new file mode 100644 index 0000000..044e5b5 --- /dev/null +++ b/.github/scripts/langCodes.py @@ -0,0 +1,60 @@ +import sys + +# Mapping between Crowdin language IDs (keys) and standard NVDA directory names (values). +# This dictionary acts as the symmetrical counterpart to 'languageMappings.json' implemented by @nvdaes. +# It ensures that translations exported from Crowdin are stored in the correct +# local paths (e.g., 'es-ES' from Crowdin goes into the 'es' folder). +CROWDIN_TO_NVDA = { + # Arabic variants + "ar-SA": "ar_SA", + + # Spanish variants + "es-ES": "es", + "es-CO": "es_CO", + + # Portuguese variants + "pt-BR": "pt_BR", + "pt-PT": "pt_PT", + + # Chinese variants + "zh-CN": "zh_CN", + "zh-HK": "zh_HK", + "zh-TW": "zh_TW", + + # Other specific mappings from the NVDA ecosystem + "af": "af_ZA", + "de-CH": "de_CH", + "nb": "nb_NO", + "nn-NO": "nn_NO", + "sr-CS": "sr" +} + +def get_nvda_code(crowdin_code): + """ + Returns the appropriate local directory name for a given Crowdin language ID. + + Args: + crowdin_code (str): The language identifier from Crowdin (e.g., 'pt-BR', 'fr'). + + Returns: + str: The corresponding NVDA locale folder name (e.g., 'pt_BR', 'fr'). + """ + # 1. Direct check in our verified map (Priority) + if crowdin_code in CROWDIN_TO_NVDA: + return CROWDIN_TO_NVDA[crowdin_code] + + # 2. Automated conversion for regional variants: Crowdin "xx-YY" -> NVDA "xx_YY" + # This handles regional codes not explicitly defined in the map. + if "-" in crowdin_code: + return crowdin_code.replace("-", "_") + + # 3. Default: Return as is. + # This covers base languages that don't use regional folders in NVDA + # (e.g., 'fr', 'tr', 'bg', 'fi', 'fa'). + return crowdin_code + +if __name__ == "__main__": + # Ensure a language code was provided as a command-line argument + if len(sys.argv) > 1: + # Standardize input and output the mapped code for PowerShell to capture + print(get_nvda_code(sys.argv[1])) \ No newline at end of file From ef0a614319e65de7d07290e44bc7c85e6436d1db Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 09:07:01 +0200 Subject: [PATCH 098/142] docs: update readme with translation workflow instructions - Add instructions for requesting developer access to the Crowdin project via the NVDA add-on mailing list. - Document required GitHub Secrets (crowdinAuthToken and CROWDIN_PROJECT_ID). - List necessary scripts and workflow files for the translation infrastructure. - Clarify the initial export and periodic download processes. --- readme.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index c8d33d2..7b777cb 100644 --- a/readme.md +++ b/readme.md @@ -177,13 +177,28 @@ If not, leave the dictionary empty. ### Translation workflow -You can add the documentation and interface messages of your add-on to be translated in Crowdin. - -You need a Crowdin account and an API token with permissions to push to a Crowdin project. -For example, you may want to use this [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons). - -Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. -When you have updated messages or documentation, run the workflow setting update to true (which is the default option). +This template allows you to automate the synchronization of documentation and interface messages with Crowdin. + +#### 1. Crowdin Project Setup +You need a Crowdin account and an API token with permissions to manage a project. +If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): +* **Request Access:** Send a message to the [NVDA add-on development/discussion list](https://nvda-addons.groups.io/g/nvda-addons) requesting an invitation to join the project as a developer. +* **API Token:** Once invited, generate an API token in your Crowdin account settings. + +#### 2. GitHub Secrets +To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): +* `crowdinAuthToken`: Paste your Crowdin API token here. +* `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. + +#### 3. Infrastructure +Ensure that your repository includes the following files (provided in this template): +* **Workflows:** `.github/workflows/exportAddonsToCrowdin.yml` and `.github/workflows/downloadTranslations.yml`. +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, and `crowdinSync.ps1`. + +#### 4. Running the Workflow +* **Initial Export:** To export your add-on to Crowdin for the first time, run the `exportAddonsToCrowdin.yml` workflow, ensuring that the "update" option is set to **false**. +* **Updates:** When you have updated messages or documentation, run the same workflow with "update" set to **true** (default). +* **Download:** The `downloadTranslations.yml` workflow will periodically (or manually) fetch new translations, verify their quality using the scripts, and create a Pull Request with the updated `.po` and `readme.md` files. ### Additional tools From 92e1cd61d6136a8713bd962867265405b2299b9d Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 09:18:05 +0200 Subject: [PATCH 099/142] docs: fix translation mailing list address in readme.md --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 7b777cb..b767408 100644 --- a/readme.md +++ b/readme.md @@ -182,11 +182,11 @@ This template allows you to automate the synchronization of documentation and in #### 1. Crowdin Project Setup You need a Crowdin account and an API token with permissions to manage a project. If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): -* **Request Access:** Send a message to the [NVDA add-on development/discussion list](https://nvda-addons.groups.io/g/nvda-addons) requesting an invitation to join the project as a developer. +* **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**) requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. #### 2. GitHub Secrets -To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): +To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): * `crowdinAuthToken`: Paste your Crowdin API token here. * `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. From 6a884c02be554638947a32dd3796b6cc3a10296b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 28 Apr 2026 20:15:06 +0200 Subject: [PATCH 100/142] Fix readme --- readme.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index b767408..3be0986 100644 --- a/readme.md +++ b/readme.md @@ -188,17 +188,15 @@ If you wish to use the community project [Crowdin project to translate NVDA add- #### 2. GitHub Secrets To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): * `crowdinAuthToken`: Paste your Crowdin API token here. -* `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. #### 3. Infrastructure Ensure that your repository includes the following files (provided in this template): -* **Workflows:** `.github/workflows/exportAddonsToCrowdin.yml` and `.github/workflows/downloadTranslations.yml`. -* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, and `crowdinSync.ps1`. +* **Workflows:** `.github/workflows/crowdinL10n.yml** +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. #### 4. Running the Workflow -* **Initial Export:** To export your add-on to Crowdin for the first time, run the `exportAddonsToCrowdin.yml` workflow, ensuring that the "update" option is set to **false**. -* **Updates:** When you have updated messages or documentation, run the same workflow with "update" set to **true** (default). -* **Download:** The `downloadTranslations.yml` workflow will periodically (or manually) fetch new translations, verify their quality using the scripts, and create a Pull Request with the updated `.po` and `readme.md` files. + +The translation workflow will be run weekly. Also, you can run the workflow manually from GitHub or using GitHub CLI. ### Additional tools From 3100c814b5a8eab29959970c86e54d6818ef9dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 29 Apr 2026 05:45:57 +0200 Subject: [PATCH 101/142] Updates --- .github/scripts/crowdinSync.ps1 | 16 ++++++++++++++-- pyproject.toml | 1 - 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index a96ae3e..18c1b9d 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -37,11 +37,14 @@ $potFile = "$addonId.pot" if (Test-Path $potFile) { Write-Host "DEBUG: Uploading updated POT source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$potFile" -c addon + Start-Sleep -Seconds 5 # Pause to avoid exceeding API limits + } if (Test-Path $xliffFile) { Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon + Start-Sleep -Seconds 5 # Pause to avoid exceeding API limits git add "$xliffFile" git diff --staged --quiet if ($LASTEXITCODE -ne 0) { @@ -67,11 +70,20 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if ($langCode -eq "en") { continue } - # Identify codes - $crowdinLang = $languageMappings[$langCode] + $mappingKeys = $languageMappings.PSObject.Properties.Name + $crowdinLang = $null + foreach ($key in $mappingKeys) { + if ($key -eq $langCode) { + $crowdinLang = $languageMappings.$key + break + } + } + Write-Host "DEBUG: langCode=$langCode, mapping=$crowdinLang" if (-not $crowdinLang) { $crowdinLang = $langCode } $langShort = $langCode.Split('-')[0].Split('_')[0] + Write-Host "CROWDIN_LANG=$crowdinLang" + # Map to local NVDA directory $localLangDir = uv run python .github/scripts/langCodes.py $langCode diff --git a/pyproject.toml b/pyproject.toml index 9fe5dd8..f0b8ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management - "requests==2.33.0", "nh3==0.3.2", "crowdin-api-client==1.24.1", "lxml==6.1.0", From 6fe4efc32bface297b7d90e324de534fc8d86a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 29 Apr 2026 05:59:19 +0200 Subject: [PATCH 102/142] Run pre-commit --- .github/scripts/checkTranslation.py | 32 +++++----- .github/scripts/crowdinSync.ps1 | 8 +-- .github/scripts/langCodes.py | 96 ++++++++++++++--------------- readme.md | 2 +- uv.lock | 2 - 5 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index b067d6d..579affd 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,6 +2,7 @@ import os from crowdin_api import CrowdinClient + def find_file_id(client, project_id, base_target, search_ext): """ Iterates through all project files (using pagination) to find the ID @@ -14,15 +15,15 @@ def find_file_id(client, project_id, base_target, search_ext): resp = client.source_files.list_files( projectId=project_id, limit=limit, - offset=offset + offset=offset, ) - data = resp['data'] + data = resp["data"] for f in data: - path_crowdin = f['data']['path'].lower() + path_crowdin = f["data"]["path"].lower() # Check if the path ends with addon_id.pot or addon_id.xliff if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f['data']['id'] + file_id = f["data"]["id"] print(f"DEBUG: Match found: {path_crowdin} (ID: {file_id})") return file_id @@ -33,6 +34,7 @@ def find_file_id(client, project_id, base_target, search_ext): return None + def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: """ Retrieves the translation progress score for a specific language and file. @@ -51,8 +53,8 @@ def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: try: # Clean and prepare search patterns # Example: 'addon/locale/fr/LC_MESSAGES/myaddon.po' -> base_target: 'myaddon' - base_target = file_name_to_search.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() - ext_target = file_name_to_search.split('.')[-1].lower() + base_target = file_name_to_search.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() + ext_target = file_name_to_search.split(".")[-1].lower() # On Crowdin, the source for a .po file is usually a .pot file search_ext = ".pot" if ext_target == "po" else f".{ext_target}" @@ -74,21 +76,21 @@ def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: projectId=p_id, fileId=file_id, limit=limit, - offset=offset + offset=offset, ) - data = resp['data'] + data = resp["data"] for item in data: - lang_api = item['data']['languageId'] - + lang_api = item["data"]["languageId"] + # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API) # Also handles underscore to dash conversion for Crowdin compatibility - if lang_api.lower().startswith(lang_id.lower().replace('_', '-')): - progress = float(item['data']['translationProgress']) + if lang_api.lower().startswith(lang_id.lower().replace("_", "-")): + progress = float(item["data"]["translationProgress"]) return progress / 100 # Check pagination total - total = resp['pagination']['totalCount'] + total = resp["pagination"]["totalCount"] if offset + limit >= total: break offset += limit @@ -100,6 +102,7 @@ def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: print(f"API ERROR: {e}") return 0.0 + def main(): if len(sys.argv) < 3: print("Usage: python checkTranslation.py ") @@ -113,7 +116,7 @@ def main(): # Output formatted for capture by the PowerShell script (crowdinSync.ps1) print(f"translationRatio={score}") - if input_file.lower().endswith('.md'): + if input_file.lower().endswith(".md"): print(f"mdScore={score}") else: print(f"poScore={score}") @@ -121,5 +124,6 @@ def main(): # Exit with success (0) if there is at least some translated content sys.exit(0 if score > 0.05 else 1) + if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 18c1b9d..2fcc011 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -38,7 +38,7 @@ if (Test-Path $potFile) { Write-Host "DEBUG: Uploading updated POT source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$potFile" -c addon Start-Sleep -Seconds 5 # Pause to avoid exceeding API limits - + } if (Test-Path $xliffFile) { @@ -66,8 +66,8 @@ New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name - + $langCode = $dir.Name + if ($langCode -eq "en") { continue } $mappingKeys = $languageMappings.PSObject.Properties.Name @@ -86,7 +86,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { # Map to local NVDA directory $localLangDir = uv run python .github/scripts/langCodes.py $langCode - + Write-Host "`n--- Processing Language: $langCode (Mapped to local: $localLangDir) ---" # Paths diff --git a/.github/scripts/langCodes.py b/.github/scripts/langCodes.py index 044e5b5..ea2780a 100644 --- a/.github/scripts/langCodes.py +++ b/.github/scripts/langCodes.py @@ -2,59 +2,57 @@ # Mapping between Crowdin language IDs (keys) and standard NVDA directory names (values). # This dictionary acts as the symmetrical counterpart to 'languageMappings.json' implemented by @nvdaes. -# It ensures that translations exported from Crowdin are stored in the correct +# It ensures that translations exported from Crowdin are stored in the correct # local paths (e.g., 'es-ES' from Crowdin goes into the 'es' folder). CROWDIN_TO_NVDA = { - # Arabic variants - "ar-SA": "ar_SA", - - # Spanish variants - "es-ES": "es", - "es-CO": "es_CO", - - # Portuguese variants - "pt-BR": "pt_BR", - "pt-PT": "pt_PT", - - # Chinese variants - "zh-CN": "zh_CN", - "zh-HK": "zh_HK", - "zh-TW": "zh_TW", - - # Other specific mappings from the NVDA ecosystem - "af": "af_ZA", - "de-CH": "de_CH", - "nb": "nb_NO", - "nn-NO": "nn_NO", - "sr-CS": "sr" + # Arabic variants + "ar-SA": "ar_SA", + # Spanish variants + "es-ES": "es", + "es-CO": "es_CO", + # Portuguese variants + "pt-BR": "pt_BR", + "pt-PT": "pt_PT", + # Chinese variants + "zh-CN": "zh_CN", + "zh-HK": "zh_HK", + "zh-TW": "zh_TW", + # Other specific mappings from the NVDA ecosystem + "af": "af_ZA", + "de-CH": "de_CH", + "nb": "nb_NO", + "nn-NO": "nn_NO", + "sr-CS": "sr", } + def get_nvda_code(crowdin_code): - """ - Returns the appropriate local directory name for a given Crowdin language ID. - - Args: - crowdin_code (str): The language identifier from Crowdin (e.g., 'pt-BR', 'fr'). - - Returns: - str: The corresponding NVDA locale folder name (e.g., 'pt_BR', 'fr'). - """ - # 1. Direct check in our verified map (Priority) - if crowdin_code in CROWDIN_TO_NVDA: - return CROWDIN_TO_NVDA[crowdin_code] - - # 2. Automated conversion for regional variants: Crowdin "xx-YY" -> NVDA "xx_YY" - # This handles regional codes not explicitly defined in the map. - if "-" in crowdin_code: - return crowdin_code.replace("-", "_") - - # 3. Default: Return as is. - # This covers base languages that don't use regional folders in NVDA - # (e.g., 'fr', 'tr', 'bg', 'fi', 'fa'). - return crowdin_code + """ + Returns the appropriate local directory name for a given Crowdin language ID. + + Args: + crowdin_code (str): The language identifier from Crowdin (e.g., 'pt-BR', 'fr'). + + Returns: + str: The corresponding NVDA locale folder name (e.g., 'pt_BR', 'fr'). + """ + # 1. Direct check in our verified map (Priority) + if crowdin_code in CROWDIN_TO_NVDA: + return CROWDIN_TO_NVDA[crowdin_code] + + # 2. Automated conversion for regional variants: Crowdin "xx-YY" -> NVDA "xx_YY" + # This handles regional codes not explicitly defined in the map. + if "-" in crowdin_code: + return crowdin_code.replace("-", "_") + + # 3. Default: Return as is. + # This covers base languages that don't use regional folders in NVDA + # (e.g., 'fr', 'tr', 'bg', 'fi', 'fa'). + return crowdin_code + if __name__ == "__main__": - # Ensure a language code was provided as a command-line argument - if len(sys.argv) > 1: - # Standardize input and output the mapped code for PowerShell to capture - print(get_nvda_code(sys.argv[1])) \ No newline at end of file + # Ensure a language code was provided as a command-line argument + if len(sys.argv) > 1: + # Standardize input and output the mapped code for PowerShell to capture + print(get_nvda_code(sys.argv[1])) diff --git a/readme.md b/readme.md index 3be0986..7d33a79 100644 --- a/readme.md +++ b/readme.md @@ -180,7 +180,7 @@ If not, leave the dictionary empty. This template allows you to automate the synchronization of documentation and interface messages with Crowdin. #### 1. Crowdin Project Setup -You need a Crowdin account and an API token with permissions to manage a project. +You need a Crowdin account and an API token with permissions to manage a project. If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): * **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**) requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. diff --git a/uv.lock b/uv.lock index 1969d46..7e17b67 100644 --- a/uv.lock +++ b/uv.lock @@ -15,7 +15,6 @@ dependencies = [ { name = "nh3" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, - { name = "requests" }, { name = "ruff" }, { name = "scons" }, { name = "uv" }, @@ -32,7 +31,6 @@ requires-dist = [ { name = "nh3", specifier = "==0.3.2" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, - { name = "requests", specifier = "==2.33.0" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, { name = "uv", specifier = "==0.11.6" }, From a3209accfc9f1ea92474930622f095cd3ce1ccce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 29 Apr 2026 06:02:20 +0200 Subject: [PATCH 103/142] Exclude langCodes.py from pyright --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f0b8ac9..a4d984c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ exclude = [ # paths are relative to the configuration file. ".github/scripts/markdownTranslate.py", ".github/scripts/checkTranslation.py", + ".github/scripts/langCodes.py", ] # Tell pyright where to load python code from From 6c0915899421d1baec28d467969fab69af487ecb Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 07:09:13 +0200 Subject: [PATCH 104/142] Improve Crowdin language handling and script clarity - Fix language mapping: correctly retrieve Crowdin-specific codes from languageMappings.json, handling differences between local codes (e.g., "es") and Crowdin IDs (e.g., "es-ES") - Remove legacy $langShort logic that caused incorrect truncation of language codes - Simplify workflow to rely entirely on the mapped $crowdinLang variable - Consolidate debug output into a single clean line: --- Processing Language: $langCode (Crowdin: $crowdinLang) --- for improved readability in GitHub Actions logs --- .github/scripts/crowdinSync.ps1 | 38 +++++++++++++++------------------ 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 2fcc011..42dfd66 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -37,14 +37,11 @@ $potFile = "$addonId.pot" if (Test-Path $potFile) { Write-Host "DEBUG: Uploading updated POT source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$potFile" -c addon - Start-Sleep -Seconds 5 # Pause to avoid exceeding API limits - } if (Test-Path $xliffFile) { Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon - Start-Sleep -Seconds 5 # Pause to avoid exceeding API limits git add "$xliffFile" git diff --staged --quiet if ($LASTEXITCODE -ne 0) { @@ -66,28 +63,27 @@ New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name - + $langCode = $dir.Name + if ($langCode -eq "en") { continue } - $mappingKeys = $languageMappings.PSObject.Properties.Name + # --- Identify codes $crowdinLang = $null - foreach ($key in $mappingKeys) { - if ($key -eq $langCode) { - $crowdinLang = $languageMappings.$key - break - } + + # Use the ."variable" syntax to correctly read the PSCustomObject from JSON + if ($languageMappings.PSObject.Properties.Name -contains $langCode) { + $crowdinLang = $languageMappings."$langCode" } - Write-Host "DEBUG: langCode=$langCode, mapping=$crowdinLang" - if (-not $crowdinLang) { $crowdinLang = $langCode } - $langShort = $langCode.Split('-')[0].Split('_')[0] - Write-Host "CROWDIN_LANG=$crowdinLang" + # Fallback: If no mapping is found, replace underscores with dashes for Crowdin compatibility + if (-not $crowdinLang) { + $crowdinLang = $langCode.Replace('_', '-') + } # Map to local NVDA directory $localLangDir = uv run python .github/scripts/langCodes.py $langCode - - Write-Host "`n--- Processing Language: $langCode (Mapped to local: $localLangDir) ---" + + Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan # Paths $remoteMd = Join-Path $dir.FullName "$addonId.md" @@ -100,8 +96,8 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { # --- 3.1 PO FILE PROCESSING --- $poImported = $false if (Test-Path $remotePo) { - Write-Host "DEBUG: Checking Remote PO progress for $langShort..." - uv run python .github/scripts/checkTranslation.py "$addonId.po" $langShort + Write-Host "DEBUG: Checking Remote PO progress for $crowdinLang..." + uv run python .github/scripts/checkTranslation.py "$addonId.po" $crowdinLang if ($LASTEXITCODE -eq 0) { Write-Host "SUCCESS: Remote PO is valid. Importing to $localPoPath" New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null @@ -123,7 +119,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (Test-Path $remoteMd) { Write-Host "DEBUG: Evaluating Remote Markdown score..." - $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $langShort + $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $crowdinLang $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] } else { Write-Host "DEBUG: No remote Markdown file found for this language." @@ -131,7 +127,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (Test-Path $remoteXliff) { Write-Host "DEBUG: Evaluating Remote XLIFF score..." - $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $langShort + $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $crowdinLang $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] } else { Write-Host "DEBUG: No remote XLIFF file found for this language." From 50b41ab3c9aee9bd16f2dd2c8910508a94035dbf Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 08:12:05 +0200 Subject: [PATCH 105/142] refactor(python): update translation score threshold to 0.5 - Changed exit condition from 0.05 to 0.5 in checkTranslation.py. - Ensures consistency with the 50% requirement used in the PowerShell workflow. --- .github/scripts/checkTranslation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 579affd..ed545a7 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -122,7 +122,7 @@ def main(): print(f"poScore={score}") # Exit with success (0) if there is at least some translated content - sys.exit(0 if score > 0.05 else 1) + sys.exit(0 if score > 0.5 else 1) if __name__ == "__main__": From 4646c5e7863f35918a9c3733fbe14ec3063d1525 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 10:16:04 +0200 Subject: [PATCH 106/142] refactor: improve XLIFF score labeling and align thresholds - Python: Added specific xliffScore label and updated exit threshold to 0.5. - PowerShell: Updated XLIFF score capture to use the new xliffScore label. - Cleaned up logic to ensure consistency across all translation types. Special thanks to @nvdaes for the insightful feedback regarding XLIFF labeling and pagination logic! --- .github/scripts/checkTranslation.py | 12 ++++++++---- .github/scripts/crowdinSync.ps1 | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index ed545a7..bb67c89 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -113,17 +113,21 @@ def main(): score = get_score_from_api(input_file, lang) - # Output formatted for capture by the PowerShell script (crowdinSync.ps1) + # Output formatted for capture by the PowerShell script print(f"translationRatio={score}") - if input_file.lower().endswith(".md"): + # Identify extension to provide a specific score label + ext = input_file.lower().split('.')[-1] + if ext == 'md': print(f"mdScore={score}") + elif ext == 'xliff': + print(f"xliffScore={score}") else: + # Default to poScore for .po and other localization files print(f"poScore={score}") - # Exit with success (0) if there is at least some translated content + # Exit with success (0) if there is at least 50% translated content sys.exit(0 if score > 0.5 else 1) - if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 42dfd66..5da9362 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -128,7 +128,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (Test-Path $remoteXliff) { Write-Host "DEBUG: Evaluating Remote XLIFF score..." $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $crowdinLang - $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] + $scoreXliff = [double]($res | Select-String "xliffScore=").ToString().Split("=")[1] } else { Write-Host "DEBUG: No remote XLIFF file found for this language." } From 56ff1e05a3e4f48fd0d6c73015ecb8d093bde2c6 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 10:53:23 +0200 Subject: [PATCH 107/142] cleanup: simplify directory mapping and remove langCodes.py - Updated crowdinSync.ps1 to use $langCode directly for local paths, as it matches NVDA's structure[cite: 2]. - Removed the intermediate $localLangDir variable for better readability[cite: 2]. - Deleted the now obsolete langCodes.py script[cite: 2]. - Improved internal documentation regarding language code handling[cite: 2]. Special thanks to @nvdaes for suggesting this optimization! --- .github/scripts/crowdinSync.ps1 | 13 ++++---- .github/scripts/langCodes.py | 58 --------------------------------- 2 files changed, 6 insertions(+), 65 deletions(-) delete mode 100644 .github/scripts/langCodes.py diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 5da9362..f245271 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -80,18 +80,17 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { $crowdinLang = $langCode.Replace('_', '-') } - # Map to local NVDA directory - $localLangDir = uv run python .github/scripts/langCodes.py $langCode - + # The $langCode (folder name from Crowdin) represents the local repository language code. + # It matches the NVDA directory structure, so no extra mapping is needed. Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan # Paths $remoteMd = Join-Path $dir.FullName "$addonId.md" $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" $remotePo = Join-Path $dir.FullName "$addonId.po" - $localMdDir = "addon/doc/$localLangDir" + $localMdDir = "addon/doc/$langCode" $localMd = "$localMdDir/readme.md" - $localPoPath = "addon/locale/$localLangDir/LC_MESSAGES/nvda.po" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" # --- 3.1 PO FILE PROCESSING --- $poImported = $false @@ -142,11 +141,11 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } if ($scoreXliff -ge $scoreMd) { - Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($localLangDir)..." + Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($langCode)..." ./l10nUtil.exe xliff2md $remoteXliff $localMd $docImported = $true } else { - Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($localLangDir)..." + Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($langCode)..." Move-Item $remoteMd $localMd -Force $docImported = $true } diff --git a/.github/scripts/langCodes.py b/.github/scripts/langCodes.py deleted file mode 100644 index ea2780a..0000000 --- a/.github/scripts/langCodes.py +++ /dev/null @@ -1,58 +0,0 @@ -import sys - -# Mapping between Crowdin language IDs (keys) and standard NVDA directory names (values). -# This dictionary acts as the symmetrical counterpart to 'languageMappings.json' implemented by @nvdaes. -# It ensures that translations exported from Crowdin are stored in the correct -# local paths (e.g., 'es-ES' from Crowdin goes into the 'es' folder). -CROWDIN_TO_NVDA = { - # Arabic variants - "ar-SA": "ar_SA", - # Spanish variants - "es-ES": "es", - "es-CO": "es_CO", - # Portuguese variants - "pt-BR": "pt_BR", - "pt-PT": "pt_PT", - # Chinese variants - "zh-CN": "zh_CN", - "zh-HK": "zh_HK", - "zh-TW": "zh_TW", - # Other specific mappings from the NVDA ecosystem - "af": "af_ZA", - "de-CH": "de_CH", - "nb": "nb_NO", - "nn-NO": "nn_NO", - "sr-CS": "sr", -} - - -def get_nvda_code(crowdin_code): - """ - Returns the appropriate local directory name for a given Crowdin language ID. - - Args: - crowdin_code (str): The language identifier from Crowdin (e.g., 'pt-BR', 'fr'). - - Returns: - str: The corresponding NVDA locale folder name (e.g., 'pt_BR', 'fr'). - """ - # 1. Direct check in our verified map (Priority) - if crowdin_code in CROWDIN_TO_NVDA: - return CROWDIN_TO_NVDA[crowdin_code] - - # 2. Automated conversion for regional variants: Crowdin "xx-YY" -> NVDA "xx_YY" - # This handles regional codes not explicitly defined in the map. - if "-" in crowdin_code: - return crowdin_code.replace("-", "_") - - # 3. Default: Return as is. - # This covers base languages that don't use regional folders in NVDA - # (e.g., 'fr', 'tr', 'bg', 'fi', 'fa'). - return crowdin_code - - -if __name__ == "__main__": - # Ensure a language code was provided as a command-line argument - if len(sys.argv) > 1: - # Standardize input and output the mapped code for PowerShell to capture - print(get_nvda_code(sys.argv[1])) From 79bdff24a525318726ab5009b810652b7660315a Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 13:10:35 +0200 Subject: [PATCH 108/142] style: apply NVDA coding standards to checkTranslation.py - Renamed functions to camelCase (findFileId, getScoreFromApi) to match NVDA standards[cite: 1]. - Added Python type annotations to improve Pyright compatibility[cite: 1]. - Included detailed docstrings using @param and @type tags for documentation[cite: 1]. - Refined the main function entry point with proper type hinting[cite: 1]. Special thanks to @nvdaes for the guidance on code standards and for offering to handle the license headers! --- .github/scripts/checkTranslation.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index bb67c89..464e8cc 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -1,12 +1,24 @@ +# checkTranslation.py import sys import os from crowdin_api import CrowdinClient -def find_file_id(client, project_id, base_target, search_ext): +def findFileId(client: CrowdinClient, project_id: int, base_target: str, search_ext: str) -> int | None: """ Iterates through all project files (using pagination) to find the ID of the source file matching the target name and extension. + + @param client: The Crowdin API client instance. + @type client: CrowdinClient + @param project_id: The ID of the Crowdin project. + @type project_id: int + @param base_target: The base name of the file (e.g., 'myaddon'). + @type base_target: str + @param search_ext: The extension to look for (e.g., '.pot'). + @type search_ext: str + @return: The file ID if found, otherwise None. + @rtype: int | None """ offset = 0 limit = 100 @@ -35,10 +47,17 @@ def find_file_id(client, project_id, base_target, search_ext): return None -def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: +def getScoreFromAPI(file_name_to_search: str, lang_id: str) -> float: """ Retrieves the translation progress score for a specific language and file. Handles pagination for both file listing and language status. + + @param file_name_to_search: The local path or name of the file to check. + @type file_name_to_search: str + @param lang_id: The language code (e.g., 'fr' or 'pt_BR'). + @type lang_id: str + @return: The translation ratio between 0.0 and 1.0. + @rtype: float """ token = os.environ.get("crowdinAuthToken") p_id_env = os.environ.get("CROWDIN_PROJECT_ID") @@ -61,7 +80,7 @@ def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: print(f"DEBUG: Searching for source file: {base_target}{search_ext}") - file_id = find_file_id(client, p_id, base_target, search_ext) + file_id = findFileId(client, p_id, base_target, search_ext) if file_id is None: print(f"WARNING: File '{base_target}{search_ext}' not found on Crowdin.") @@ -111,7 +130,7 @@ def main(): input_file = sys.argv[1] lang = sys.argv[2] - score = get_score_from_api(input_file, lang) + score = getScoreFromAPI(input_file, lang) # Output formatted for capture by the PowerShell script print(f"translationRatio={score}") From 76ed71eff8510e549f03131f7a796c7b0602b259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 29 Apr 2026 17:17:22 +0200 Subject: [PATCH 109/142] Fit to coding standards --- .github/scripts/checkTranslation.py | 101 +++++++++++++--------------- .github/scripts/setOutputs.py | 1 + 2 files changed, 49 insertions(+), 53 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 464e8cc..3207916 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -1,31 +1,29 @@ # checkTranslation.py +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + import sys import os from crowdin_api import CrowdinClient -def findFileId(client: CrowdinClient, project_id: int, base_target: str, search_ext: str) -> int | None: +def findFileId(client: CrowdinClient, projectId: int, baseTarget: str, searchExt: str) -> int | None: """ - Iterates through all project files (using pagination) to find the ID - of the source file matching the target name and extension. + Iterates through all project files (using pagination) to find the ID of the source file matching the target name and extension. - @param client: The Crowdin API client instance. - @type client: CrowdinClient - @param project_id: The ID of the Crowdin project. - @type project_id: int - @param base_target: The base name of the file (e.g., 'myaddon'). - @type base_target: str - @param search_ext: The extension to look for (e.g., '.pot'). - @type search_ext: str - @return: The file ID if found, otherwise None. - @rtype: int | None + :param client: The Crowdin API client instance. + :param projectId: The ID of the Crowdin project. + :param base_target: The base name of the file (e.g., 'myAddon). + :param search_ext: The extension to look for (e.g., '.pot'). + :return: The file ID if found, otherwise None. """ offset = 0 limit = 100 while True: resp = client.source_files.list_files( - projectId=project_id, + projectId=projectId, limit=limit, offset=offset, ) @@ -33,11 +31,11 @@ def findFileId(client: CrowdinClient, project_id: int, base_target: str, search_ data = resp["data"] for f in data: path_crowdin = f["data"]["path"].lower() - # Check if the path ends with addon_id.pot or addon_id.xliff - if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f["data"]["id"] - print(f"DEBUG: Match found: {path_crowdin} (ID: {file_id})") - return file_id + # Check if the path ends with addon_id.pot or addon_id.xliff. + if path_crowdin.endswith(f"{baseTarget}{searchExt}"): + fileId = f["data"]["id"] + print(f"DEBUG: Match found: {path_crowdin} (ID: {fileId})") + return fileId if len(data) < limit: break @@ -47,74 +45,71 @@ def findFileId(client: CrowdinClient, project_id: int, base_target: str, search_ return None -def getScoreFromAPI(file_name_to_search: str, lang_id: str) -> float: +def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: """ Retrieves the translation progress score for a specific language and file. Handles pagination for both file listing and language status. - @param file_name_to_search: The local path or name of the file to check. - @type file_name_to_search: str - @param lang_id: The language code (e.g., 'fr' or 'pt_BR'). - @type lang_id: str - @return: The translation ratio between 0.0 and 1.0. - @rtype: float + :param fileNameToSearch: The local path or name of the file to check. + :param langId: The language code (e.g., 'fr' or 'pt_BR'). + :return: The translation ratio between 0.0 and 1.0. """ token = os.environ.get("crowdinAuthToken") - p_id_env = os.environ.get("CROWDIN_PROJECT_ID") + projectIdEnv = os.environ.get("CROWDIN_PROJECT_ID") - if not token or not p_id_env: + if not token or not projectIdEnv: print("ERROR: Missing environment variables 'crowdinAuthToken' or 'CROWDIN_PROJECT_ID'.") return 0.0 client = CrowdinClient(token=token) - p_id = int(p_id_env) + projectId = int(projectIdEnv) try: - # Clean and prepare search patterns - # Example: 'addon/locale/fr/LC_MESSAGES/myaddon.po' -> base_target: 'myaddon' - base_target = file_name_to_search.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() - ext_target = file_name_to_search.split(".")[-1].lower() + # Clean and prepare search patterns. + # Example: 'addon/locale/fr/LC_MESSAGES/myAddon.po' -> base_target: 'myAddon'. + baseTarget = fileNameToSearch.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() + extTarget = fileNameToSearch.split(".")[-1].lower() - # On Crowdin, the source for a .po file is usually a .pot file - search_ext = ".pot" if ext_target == "po" else f".{ext_target}" + # On Crowdin, the source for a .po file is usually a .pot file. + searchExt = ".pot" if extTarget == "po" else f".{extTarget}" - print(f"DEBUG: Searching for source file: {base_target}{search_ext}") + print(f"DEBUG: Searching for source file: {baseTarget}{searchExt}") - file_id = findFileId(client, p_id, base_target, search_ext) + fileId = findFileId(client, projectId, baseTarget, searchExt) - if file_id is None: - print(f"WARNING: File '{base_target}{search_ext}' not found on Crowdin.") + if fileId is None: + print(f"WARNING: File '{baseTarget}{searchExt}' not found on Crowdin.") return 0.0 - # Pagination for translation status (Progress) + # Pagination for translation status (Progress). offset = 0 limit = 100 while True: resp = client.translation_status.get_file_progress( - projectId=p_id, - fileId=file_id, + projectId=projectId, + fileId=fileId, limit=limit, offset=offset, ) data = resp["data"] for item in data: - lang_api = item["data"]["languageId"] + langApi = item["data"]["language_id"] - # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API) + # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API). # Also handles underscore to dash conversion for Crowdin compatibility - if lang_api.lower().startswith(lang_id.lower().replace("_", "-")): + if langApi.lower().startswith(langId.lower().replace("_", "-")): progress = float(item["data"]["translationProgress"]) return progress / 100 - # Check pagination total + # Check pagination total. total = resp["pagination"]["totalCount"] if offset + limit >= total: break offset += limit - print(f"DEBUG: Language '{lang_id}' not found in progress list for this file.") + print(f"DEBUG: Language '{langId}' not found in progress list for this file.") return 0.0 except Exception as e: @@ -124,28 +119,28 @@ def getScoreFromAPI(file_name_to_search: str, lang_id: str) -> float: def main(): if len(sys.argv) < 3: - print("Usage: python checkTranslation.py ") + print("Usage: python checkTranslation.py ") sys.exit(2) input_file = sys.argv[1] lang = sys.argv[2] - score = getScoreFromAPI(input_file, lang) + score = getScoreFromApi(input_file, lang) - # Output formatted for capture by the PowerShell script + # Output formatted for capture by the PowerShell script. print(f"translationRatio={score}") - # Identify extension to provide a specific score label + # Identify extension to provide a specific score label. ext = input_file.lower().split('.')[-1] if ext == 'md': print(f"mdScore={score}") elif ext == 'xliff': print(f"xliffScore={score}") else: - # Default to poScore for .po and other localization files + # Default to poScore for .po and other localization files. print(f"poScore={score}") - # Exit with success (0) if there is at least 50% translated content + # Exit with success (0) if there is at least 50% translated content. sys.exit(0 if score > 0.5 else 1) if __name__ == "__main__": diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index a5d9161..ced3f2c 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -1,3 +1,4 @@ +# setOutputs.py # Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. From e423d6fc2ad2aa402712e15fbf750049cf800de0 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 29 Apr 2026 17:41:54 +0200 Subject: [PATCH 110/142] fix: correct Crowdin API dictionary key for language identification The recent refactoring to camelCase accidentally changed the dictionary key 'languageId' to 'language_id' when accessing the API response data. Since the Crowdin API strictly returns 'languageId', this caused a KeyError. - Restored 'languageId' in getScoreFromApi function to ensure proper score retrieval[cite: 2]. - Verified that other internal camelCase variables remain unchanged to satisfy NVDA coding standards[cite: 2]. --- .github/scripts/checkTranslation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 3207916..e732561 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -95,7 +95,7 @@ def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: data = resp["data"] for item in data: - langApi = item["data"]["language_id"] + langApi = item["data"]["languageId"] # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API). # Also handles underscore to dash conversion for Crowdin compatibility From 2d477f9ccb8f4a5aa2818c96727f16e7347efab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 29 Apr 2026 18:05:30 +0200 Subject: [PATCH 111/142] Fix pre-commit --- .github/scripts/checkTranslation.py | 11 ++++++----- .github/scripts/crowdinSync.ps1 | 10 +++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index e732561..acf45e1 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -11,7 +11,7 @@ def findFileId(client: CrowdinClient, projectId: int, baseTarget: str, searchExt: str) -> int | None: """ Iterates through all project files (using pagination) to find the ID of the source file matching the target name and extension. - + :param client: The Crowdin API client instance. :param projectId: The ID of the Crowdin project. :param base_target: The base name of the file (e.g., 'myAddon). @@ -49,7 +49,7 @@ def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: """ Retrieves the translation progress score for a specific language and file. Handles pagination for both file listing and language status. - + :param fileNameToSearch: The local path or name of the file to check. :param langId: The language code (e.g., 'fr' or 'pt_BR'). :return: The translation ratio between 0.0 and 1.0. @@ -131,10 +131,10 @@ def main(): print(f"translationRatio={score}") # Identify extension to provide a specific score label. - ext = input_file.lower().split('.')[-1] - if ext == 'md': + ext = input_file.lower().split(".")[-1] + if ext == "md": print(f"mdScore={score}") - elif ext == 'xliff': + elif ext == "xliff": print(f"xliffScore={score}") else: # Default to poScore for .po and other localization files. @@ -143,5 +143,6 @@ def main(): # Exit with success (0) if there is at least 50% translated content. sys.exit(0 if score > 0.5 else 1) + if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index f245271..3f16c8d 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -63,21 +63,21 @@ New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name - + $langCode = $dir.Name + if ($langCode -eq "en") { continue } # --- Identify codes $crowdinLang = $null - + # Use the ."variable" syntax to correctly read the PSCustomObject from JSON if ($languageMappings.PSObject.Properties.Name -contains $langCode) { $crowdinLang = $languageMappings."$langCode" } # Fallback: If no mapping is found, replace underscores with dashes for Crowdin compatibility - if (-not $crowdinLang) { - $crowdinLang = $langCode.Replace('_', '-') + if (-not $crowdinLang) { + $crowdinLang = $langCode.Replace('_', '-') } # The $langCode (folder name from Crowdin) represents the local repository language code. From fcc571a0ace781c17300986cf008caae1aa8fab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 17:59:20 +0200 Subject: [PATCH 112/142] Update .github/scripts/crowdinSync.ps1 Remove temp file as a general good practice, though this is not needed since this is intended just to be used with GitHub Actions. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/scripts/crowdinSync.ps1 | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 3f16c8d..c8e0ddc 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -19,9 +19,15 @@ $mdFile = "./readme.md" if (Test-Path $mdFile) { if (Test-Path $xliffFile) { $tempXliff = [System.IO.Path]::GetTempFileName() - Copy-Item "$addonId.xliff" $tempXliff -Force - Write-Host "DEBUG: Updating XLIFF source based on readme.md..." - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + try { + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "DEBUG: Updating XLIFF source based on readme.md..." + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + } finally { + if (Test-Path $tempXliff) { + Remove-Item $tempXliff -Force + } + } } else { Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile From 043e6f6ea159853428a497085f0e833081fc428a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 18:16:39 +0200 Subject: [PATCH 113/142] Update readme addressing review, and adding the add-ons mailing list as a way to request an invitation --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 7d33a79..0b8e975 100644 --- a/readme.md +++ b/readme.md @@ -182,12 +182,12 @@ This template allows you to automate the synchronization of documentation and in #### 1. Crowdin Project Setup You need a Crowdin account and an API token with permissions to manage a project. If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): -* **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**) requesting an invitation to join the project as a developer. +* **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**), or in the [NVDA Add-ons Mailing List](https://groups.io/g/nvda-addons) (**nvda-addons@groups.io**), requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. #### 2. GitHub Secrets To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): -* `crowdinAuthToken`: Paste your Crowdin API token here. +* `CROWDIN_TOKEN`: Paste your Crowdin API token here. #### 3. Infrastructure Ensure that your repository includes the following files (provided in this template): From f577977936be3da881e4f1f011d94e72b20a5d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 18:20:14 +0200 Subject: [PATCH 114/142] Update readme.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 0b8e975..9ac28ac 100644 --- a/readme.md +++ b/readme.md @@ -191,8 +191,8 @@ To allow the workflows to communicate with Crowdin, you must add the following s #### 3. Infrastructure Ensure that your repository includes the following files (provided in this template): -* **Workflows:** `.github/workflows/crowdinL10n.yml** -* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. +* **Workflows:** `.github/workflows/crowdinL10n.yml` +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `markdownTranslate.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. #### 4. Running the Workflow From ea5665b8ca7d5f3b789043378ebabb92dcc51f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 18:21:46 +0200 Subject: [PATCH 115/142] Update pyproject.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a4d984c..f0b8ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,6 @@ exclude = [ # paths are relative to the configuration file. ".github/scripts/markdownTranslate.py", ".github/scripts/checkTranslation.py", - ".github/scripts/langCodes.py", ] # Tell pyright where to load python code from From b754a426b91a72b4e99a5a6e9e2f032118f8395a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 18:26:51 +0200 Subject: [PATCH 116/142] Update .github/scripts/crowdinSync.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/scripts/crowdinSync.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index c8e0ddc..49e1a0b 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -5,11 +5,12 @@ $ErrorActionPreference = 'Stop' git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" -$addonId = $env:ADDON_ID.Trim() -if (-not $addonId) { +$rawAddonId = $env:ADDON_ID +if ([string]::IsNullOrWhiteSpace($rawAddonId)) { Write-Error "Failed to get addon ID." exit 1 } +$addonId = $rawAddonId.Trim() # --- STEP 1: PREPARATION AND SOURCE UPDATE --- From ea8741a07b93211208f4f2221541463a9bdb2966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 30 Apr 2026 18:52:33 +0200 Subject: [PATCH 117/142] Advice to avoid running workflows at the same time in different repos to prevent failures --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 9ac28ac..c3fde86 100644 --- a/readme.md +++ b/readme.md @@ -198,6 +198,8 @@ Ensure that your repository includes the following files (provided in this templ The translation workflow will be run weekly. Also, you can run the workflow manually from GitHub or using GitHub CLI. +If you have various add-ons, please edit the cron line of workflows in each repo, so that your API token is not used at the same time. + ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From 3741ad8d087933b5e3aedfc3326d36baa996b076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:25:22 +0200 Subject: [PATCH 118/142] Exclude .github/scripts --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f0b8ac9..fb274a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,11 +101,10 @@ exclude = [ "__pycache__", ".venv", "site_scons", + ".github/scripts", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. - ".github/scripts/markdownTranslate.py", - ".github/scripts/checkTranslation.py", ] # Tell pyright where to load python code from From eccd12a4201ef7e8f1e42c381b685ec1707a242c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:27:21 +0200 Subject: [PATCH 119/142] Update .github/scripts/setOutputs.py Co-authored-by: Sean Budd --- .github/scripts/setOutputs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index ced3f2c..81385ae 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -1,5 +1,4 @@ -# setOutputs.py -# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez +# Copyright (C) 2025-2026 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. From 5ccf09b3c7f179ac16dbe9fa428b7c2adbc39140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:28:07 +0200 Subject: [PATCH 120/142] Update .github/scripts/markdownTranslate.py Co-authored-by: Sean Budd --- .github/scripts/markdownTranslate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py index d9cbaf5..165e940 100644 --- a/.github/scripts/markdownTranslate.py +++ b/.github/scripts/markdownTranslate.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024 NV Access Limited. +# Copyright (C) 2024-2026 NV Access Limited. # This file is covered by the GNU General Public License. # See the file COPYING for more details. From 8082e88030353eaec1187dfc6e0a6a3199c1d050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:28:43 +0200 Subject: [PATCH 121/142] Update readme.md Co-authored-by: Sean Budd --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index c3fde86..8bd315e 100644 --- a/readme.md +++ b/readme.md @@ -180,8 +180,10 @@ If not, leave the dictionary empty. This template allows you to automate the synchronization of documentation and interface messages with Crowdin. #### 1. Crowdin Project Setup + You need a Crowdin account and an API token with permissions to manage a project. If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): + * **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**), or in the [NVDA Add-ons Mailing List](https://groups.io/g/nvda-addons) (**nvda-addons@groups.io**), requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. From 64e07f3d2d64295c11f59abf93e19eb4e4e4e5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:29:51 +0200 Subject: [PATCH 122/142] Update .github/workflows/crowdinL10n.yml Co-authored-by: Sean Budd --- .github/workflows/crowdinL10n.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 8ec5efc..a19f8f3 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -14,7 +14,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} - CROWDIN_PROJECT_ID: 780748 + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} jobs: crowdinSync: From 4eecb1b01c8aa263da83040f3db3ec817e1adb58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 4 May 2026 21:30:50 +0200 Subject: [PATCH 123/142] Update .github/scripts/checkTranslation.py Co-authored-by: Sean Budd --- .github/scripts/checkTranslation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index acf45e1..346a9ef 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -1,4 +1,3 @@ -# checkTranslation.py # Copyright (C) 2026 NV Access Limited, Abdel # This file is covered by the GNU General Public License. # See the file COPYING for more details. From fa63dfcde41c6498c3ff76b8941c8eb7a28e7edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 07:14:17 +0200 Subject: [PATCH 124/142] Run pre-commit --- .github/workflows/crowdinL10n.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a19f8f3..9088707 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -14,7 +14,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} - CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} jobs: crowdinSync: From 1c1f3606813ba9abb7b247df2569a5572906b5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 20:15:58 +0200 Subject: [PATCH 125/142] Add instruction to delay workflow randomly for a maximum of 5 minutes --- .github/workflows/crowdinL10n.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 9088707..38a1b30 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -23,6 +23,13 @@ jobs: contents: write steps: + - name: Random startup delay (0-5 minutes) + if: github.event_name == 'schedule' + shell: pwsh + run: | + $delaySeconds = Get-Random -Minimum 0 -Maximum 301 + Write-Host "Sleeping for $delaySeconds seconds..." + Start-Sleep -Seconds $delaySeconds - name: Checkout add-on uses: actions/checkout@v6 with: From 875ffb243c67e0bb59debe9d9b4ad0cb769c4b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 20:38:55 +0200 Subject: [PATCH 126/142] Add ability to set a different Crowdin project --- .github/scripts/crowdinSync.ps1 | 10 +++++----- .github/workflows/crowdinL10n.yml | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 49e1a0b..9628dd9 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -43,12 +43,12 @@ $potFile = "$addonId.pot" if (Test-Path $potFile) { Write-Host "DEBUG: Uploading updated POT source to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$potFile" -c addon + ./l10nUtil.exe uploadSourceFile "$potFile" -c $env:L10N_UTIL_CONFIG } if (Test-Path $xliffFile) { Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon + ./l10nUtil.exe uploadSourceFile "$xliffFile" -c $env:L10N_UTIL_CONFIG git add "$xliffFile" git diff --staged --quiet if ($LASTEXITCODE -ne 0) { @@ -60,7 +60,7 @@ if (Test-Path $xliffFile) { # --- STEP 3: EXPORT AND PROCESS TRANSLATIONS --- Write-Host "DEBUG: Exporting translations from Crowdin..." -./l10nUtil.exe exportTranslations -o _addonL10n -c addon +./l10nUtil.exe exportTranslations -o _addonL10n -c $env:L10N_UTIL_CONFIG # Ensure base directories exist New-Item -ItemType Directory -Force -Path addon/locale | Out-Null @@ -116,7 +116,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (-not $poImported -and (Test-Path $localPoPath)) { Write-Host "ACTION: Uploading local legacy PO to Crowdin ($crowdinLang) as fallback." - ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.po" $localPoPath -c addon + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.po" $localPoPath -c $env:L10N_UTIL_CONFIG } # --- 3.2 DOCUMENTATION PROCESSING (MD & XLIFF) --- @@ -162,7 +162,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { if (-not $docImported -and (Test-Path $localMd)) { Write-Host "ACTION: Documentation quality too low. Uploading local MD to Crowdin ($crowdinLang) as fallback." - ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c addon + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c $env:L10N_UTIL_CONFIG } } diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 38a1b30..60a1228 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -15,6 +15,7 @@ env: downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} + L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || addon }} jobs: crowdinSync: From dfe164f8a1cfa8b0ad055cb6c5955c151a36dcc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 21:02:33 +0200 Subject: [PATCH 127/142] Fix workflow --- .github/workflows/crowdinL10n.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 60a1228..223feaf 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -15,7 +15,7 @@ env: downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} - L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || addon }} + L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || 'addon' }} jobs: crowdinSync: From e78b4ac6a2a1b512c5256074c7d4249e215b5e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 21:13:27 +0200 Subject: [PATCH 128/142] Add instructions to use a different Crowdin project --- readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/readme.md b/readme.md index 8bd315e..7290497 100644 --- a/readme.md +++ b/readme.md @@ -191,6 +191,12 @@ If you wish to use the community project [Crowdin project to translate NVDA add- To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): * `CROWDIN_TOKEN`: Paste your Crowdin API token here. +Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), please add the following variables: + +* `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. +* L10N_UTIL_CONFIG: The path to the yaml file containing the configuration for the nvdaL10nUtil.exe file, used by the translation scripts. +For more details, visit the [nvdaL10n repositori](https://github.com/nvaccess/nvdaL10n). + #### 3. Infrastructure Ensure that your repository includes the following files (provided in this template): * **Workflows:** `.github/workflows/crowdinL10n.yml` From f7631606137fdf6a5dfd7a6196028a89d28debed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 5 May 2026 21:15:22 +0200 Subject: [PATCH 129/142] Update readme --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 7290497..7ddcc5a 100644 --- a/readme.md +++ b/readme.md @@ -194,7 +194,7 @@ To allow the workflows to communicate with Crowdin, you must add the following s Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), please add the following variables: * `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. -* L10N_UTIL_CONFIG: The path to the yaml file containing the configuration for the nvdaL10nUtil.exe file, used by the translation scripts. +* L10N_UTIL_CONFIG: The path to the yaml file containing the configuration for the l10nUtil.exe file, used by the translation scripts. For more details, visit the [nvdaL10n repositori](https://github.com/nvaccess/nvdaL10n). #### 3. Infrastructure From 5c5d507121ba742575a57e47d04a851a01624b34 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 23 May 2026 21:34:25 +0200 Subject: [PATCH 130/142] Push Crowdin translation updates to the current workflow branch This removes the dedicated l10n branch handling previously used during translation synchronization. Translation updates are now pushed directly to the branch checked out by the workflow instead of being force-pushed to l10n. Also removes the obsolete downloadTranslationsBranch workflow environment variable, which is no longer needed. Additionally, translation synchronization now performs a single final git push after all commits have been created, avoiding intermediate repository states during workflow execution. --- .github/scripts/crowdinSync.ps1 | 20 ++++++++++++++++---- .github/workflows/crowdinL10n.yml | 5 ++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 9628dd9..63dafc8 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -53,7 +53,6 @@ if (Test-Path $xliffFile) { git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update $xliffFile for $addonId" - git push } } @@ -172,9 +171,22 @@ git add addon/locale addon/doc git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin (Automatic Sync)" - $branch = $env:downloadTranslationsBranch - git push -f origin "HEAD:$branch" - Write-Host "SUCCESS: Translations committed and pushed." + Write-Host "SUCCESS: Translations committed." } else { Write-Host "DEBUG: No changes in translations to commit." } + +# Push all generated commits after successful Crowdin synchronization +$pushOutput = git push 2>&1 + +Write-Host $pushOutput + +if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to push commits to $repository." +} +elseif ($pushOutput -match "Everything up-to-date") { + Write-Host "INFO: No new commits needed to be pushed." +} +else { + Write-Host "SUCCESS: New commits successfully pushed to $repository." +} diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 223feaf..c23978d 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,8 +3,8 @@ name: Crowdin l10n on: workflow_dispatch: schedule: - # Every Monday at 00:00 UTC - - cron: '0 0 * * 1' + # Every Thursday at 09:32 UTC + - cron: '32 9 * * 4' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -12,7 +12,6 @@ concurrency: env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || 'addon' }} From 6d6a535160c8bc441e01fbcecb6a37e4121d9aa5 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 23 May 2026 21:44:58 +0200 Subject: [PATCH 131/142] Restore previous Monday 00:00 UTC schedule for Crowdin workflow --- .github/workflows/crowdinL10n.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index c23978d..24853d5 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,8 +3,8 @@ name: Crowdin l10n on: workflow_dispatch: schedule: - # Every Thursday at 09:32 UTC - - cron: '32 9 * * 4' + # Every Monday at 00:00 UTC + - cron: '0 0 * * 1' concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 7c9ae978e2505dd9ea4a038530c2ec682e3b2035 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 24 May 2026 01:11:58 +0200 Subject: [PATCH 132/142] chore(ci): add repository variable to crowdinSync.ps1 script Declare the $repository variable using $env:GITHUB_REPOSITORY. This will be used to reference the repository name in debug and logging messages. --- .github/scripts/crowdinSync.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 63dafc8..9b99315 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -179,6 +179,9 @@ if ($LASTEXITCODE -ne 0) { # Push all generated commits after successful Crowdin synchronization $pushOutput = git push 2>&1 +# Get the full repository name in "owner/repository" format (e.g., hkatic/clock) +$repository = $env:GITHUB_REPOSITORY + Write-Host $pushOutput if ($LASTEXITCODE -ne 0) { From fd601705f3beb7aae9adbb99ec196f30e6d12fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 25 May 2026 07:09:09 +0200 Subject: [PATCH 133/142] Use l10nUtil to generate and update xliff --- .github/scripts/crowdinSync.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 9b99315..6047207 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -23,7 +23,7 @@ if (Test-Path $mdFile) { try { Copy-Item "$addonId.xliff" $tempXliff -Force Write-Host "DEBUG: Updating XLIFF source based on readme.md..." - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + ./l10nUtil.exe md2xliff $mdFile $xliffFile -o $tempXliff } finally { if (Test-Path $tempXliff) { Remove-Item $tempXliff -Force @@ -31,7 +31,7 @@ if (Test-Path $mdFile) { } } else { Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." - uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile + ./l10nUtil.exe md2xliff $mdFile $xliffFile } } From 1e15f978d50f0675992277b60ace756339b5c128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 25 May 2026 07:10:01 +0200 Subject: [PATCH 134/142] Remove markdownTranslate --- .github/scripts/markdownTranslate.py | 881 --------------------------- 1 file changed, 881 deletions(-) delete mode 100644 .github/scripts/markdownTranslate.py diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py deleted file mode 100644 index 165e940..0000000 --- a/.github/scripts/markdownTranslate.py +++ /dev/null @@ -1,881 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2026 NV Access Limited. -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -from collections.abc import Generator -from collections.abc import Iterable -import tempfile -import os -import contextlib -import lxml.etree -import argparse -import uuid -import re -from itertools import zip_longest -from xml.sax.saxutils import escape as xmlEscape -import difflib -from dataclasses import dataclass -import subprocess - - -def getGithubRepoURL() -> str | None: - """ - Get the GitHub repository URL from git remote origin. - return: The raw GitHub URL for the repository, or None if it cannot be determined. - """ - result = subprocess.run( - ["git", "remote", "get-url", "origin"], - capture_output=True, - text=True, - check=True, - ) - remote_url = result.stdout.strip() - # Convert SSH or HTTPS URL to raw GitHub URL format - if match := re.match(r"git@github\.com:(.+?)(?:\.git)?$", remote_url): - repo_path = match.group(1) - elif match := re.match(r"https://github\.com/(.+?)(?:\.git)?$", remote_url): - repo_path = match.group(1) - else: - raise ValueError(f"Cannot parse GitHub URL from git remote: {remote_url}") - return f"https://raw.githubusercontent.com/{repo_path}" - - -re_kcTitle = re.compile(r"^()$") -re_kcSettingsSection = re.compile(r"^()$") -# Comments that span a single line in their entirety -re_comment = re.compile(r"^$") -re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") -re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") -re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") -re_hiddenHeaderRow = re.compile(r"^\|\s*\.\s*\{\.hideHeaderRow\}\s*(\|\s*\.\s*)*\|$") -re_postTableHeaderLine = re.compile(r"^(\|\s*-+\s*)+\|$") -re_tableRow = re.compile(r"^(\|)(.+)(\|)$") -re_translationID = re.compile(r"^(.*)\$\(ID:([0-9a-f-]+)\)(.*)$") -re_inlineMarkdownLintComment = re.compile(r"^(.*?)(?:\s*)(\s*)$") - - -def prettyPathString(path: str) -> str: - cwd = os.getcwd() - if os.path.normcase(os.path.splitdrive(path)[0]) != os.path.normcase( - os.path.splitdrive(cwd)[0], - ): - return path - return os.path.relpath(path, cwd) - - -@contextlib.contextmanager -def createAndDeleteTempFilePath_contextManager( - dir: str | None = None, - prefix: str | None = None, - suffix: str | None = None, -) -> Generator[str, None, None]: - """A context manager that creates a temporary file and deletes it when the context is exited""" - with tempfile.NamedTemporaryFile( - dir=dir, - prefix=prefix, - suffix=suffix, - delete=False, - ) as tempFile: - tempFilePath = tempFile.name - tempFile.close() - yield tempFilePath - os.remove(tempFilePath) - - -def getLastCommitID(filePath: str) -> str: - # Run the git log command to get the last commit ID for the given file - result = subprocess.run( - ["git", "log", "-n", "1", "--pretty=format:%H", "--", filePath], - capture_output=True, - text=True, - check=True, - ) - commitID = result.stdout.strip() - if not re.match(r"[0-9a-f]{40}", commitID): - raise ValueError(f"Invalid commit ID: '{commitID}' for file '{filePath}'") - return commitID - - -def getGitDir() -> str: - # Run the git rev-parse command to get the root of the git directory - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=True, - ) - gitDir = result.stdout.strip() - if not os.path.isdir(gitDir): - raise ValueError(f"Invalid git directory: '{gitDir}'") - return gitDir - - -def getRawGithubURLForPath(filePath: str) -> str: - gitDirPath = getGitDir() - commitID = getLastCommitID(filePath) - relativePath = os.path.relpath(os.path.abspath(filePath), gitDirPath) - relativePath = relativePath.replace("\\", "/") - rawGithubRepoUrl = getGithubRepoURL() - return f"{rawGithubRepoUrl}/{commitID}/{relativePath}" - - -def preprocessMarkdownLines(mdLines: Iterable[str]) -> Iterable[str]: - """ - Preprocess markdown lines such as removing inline markdown lint comments.\ - :param mdLines: The markdown lines to preprocess - :returns: The preprocessed markdown lines - """ - for mdLine in mdLines: - # #18982: Remove markdown lint comments completely - not needed for intermediate markdown or final html. - mdLine = re_inlineMarkdownLintComment.sub(r"\1\2", mdLine) - yield mdLine - - -def skeletonizeLine(mdLine: str) -> str | None: - prefix = "" - suffix = "" - if ( - mdLine.isspace() - or mdLine.strip() == "[TOC]" - or re_hiddenHeaderRow.match(mdLine) - or re_postTableHeaderLine.match(mdLine) - ): - return None - elif m := re_heading.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_bullet.match(mdLine): - prefix, content = m.groups() - elif m := re_number.match(mdLine): - prefix, content = m.groups() - elif m := re_tableRow.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcTitle.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcSettingsSection.match(mdLine): - prefix, content, suffix = m.groups() - elif re_comment.match(mdLine): - return None - ID = str(uuid.uuid4()) - return f"{prefix}$(ID:{ID}){suffix}\n" - - -@dataclass -class Result_generateSkeleton: - numTotalLines: int = 0 - numTranslationPlaceholders: int = 0 - - -def generateSkeleton(mdPath: str, outputPath: str) -> Result_generateSkeleton: - print( - f"Generating skeleton file {prettyPathString(outputPath)} from {prettyPathString(mdPath)}...", - ) - res = Result_generateSkeleton() - with ( - open(mdPath, "r", encoding="utf8") as mdFile, - open(outputPath, "w", encoding="utf8", newline="") as outputFile, - ): - for mdLine in preprocessMarkdownLines(mdFile.readlines()): - res.numTotalLines += 1 - skelLine = skeletonizeLine(mdLine) - if skelLine: - res.numTranslationPlaceholders += 1 - else: - skelLine = mdLine - outputFile.write(skelLine) - print( - f"Generated skeleton file with {res.numTotalLines} total lines and {res.numTranslationPlaceholders} translation placeholders", - ) - return res - - -@dataclass -class Result_updateSkeleton: - numAddedLines: int = 0 - numAddedTranslationPlaceholders: int = 0 - numRemovedLines: int = 0 - numRemovedTranslationPlaceholders: int = 0 - numUnchangedLines: int = 0 - numUnchangedTranslationPlaceholders: int = 0 - - -def extractSkeleton(xliffPath: str, outputPath: str): - print( - f"Extracting skeleton from {prettyPathString(xliffPath)} to {prettyPathString(outputPath)}...", - ) - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - outputFile.write(skeletonContent) - print(f"Extracted skeleton to {prettyPathString(outputPath)}") - - -def updateSkeleton( - origMdPath: str, - newMdPath: str, - origSkelPath: str, - outputPath: str, -) -> Result_updateSkeleton: - print( - f"Creating updated skeleton file {prettyPathString(outputPath)} from {prettyPathString(origSkelPath)} with changes from {prettyPathString(origMdPath)} to {prettyPathString(newMdPath)}...", - ) - res = Result_updateSkeleton() - with contextlib.ExitStack() as stack: - origMdFile = stack.enter_context(open(origMdPath, "r", encoding="utf8")) - newMdFile = stack.enter_context(open(newMdPath, "r", encoding="utf8")) - origSkelFile = stack.enter_context(open(origSkelPath, "r", encoding="utf8")) - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - origMdLines = preprocessMarkdownLines(origMdFile.readlines()) - newMdLines = preprocessMarkdownLines(newMdFile.readlines()) - mdDiff = difflib.ndiff(list(origMdLines), list(newMdLines)) - origSkelLines = iter(origSkelFile.readlines()) - for mdDiffLine in mdDiff: - if mdDiffLine.startswith("?"): - continue - if mdDiffLine.startswith(" "): - res.numUnchangedLines += 1 - skelLine = next(origSkelLines) - if re_translationID.match(skelLine): - res.numUnchangedTranslationPlaceholders += 1 - outputFile.write(skelLine) - elif mdDiffLine.startswith("+"): - res.numAddedLines += 1 - skelLine = skeletonizeLine(mdDiffLine[2:]) - if skelLine: - res.numAddedTranslationPlaceholders += 1 - else: - skelLine = mdDiffLine[2:] - outputFile.write(skelLine) - elif mdDiffLine.startswith("-"): - res.numRemovedLines += 1 - origSkelLine = next(origSkelLines) - if re_translationID.match(origSkelLine): - res.numRemovedTranslationPlaceholders += 1 - else: - raise ValueError(f"Unexpected diff line: {mdDiffLine}") - print( - f"Updated skeleton file with {res.numAddedLines} added lines " - f"({res.numAddedTranslationPlaceholders} translation placeholders), " - f"{res.numRemovedLines} removed lines ({res.numRemovedTranslationPlaceholders} translation placeholders), " - f"and {res.numUnchangedLines} unchanged lines ({res.numUnchangedTranslationPlaceholders} translation placeholders)", - ) - return res - - -@dataclass -class Result_generateXliff: - numTranslatableStrings: int = 0 - - -def generateXliff( - mdPath: str, - outputPath: str, - skelPath: str | None = None, -) -> Result_generateXliff: - # If a skeleton file is not provided, first generate one - with contextlib.ExitStack() as stack: - if not skelPath: - skelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=os.path.dirname(outputPath), - prefix=os.path.basename(mdPath), - suffix=".skel", - ), - ) - generateSkeleton(mdPath=mdPath, outputPath=skelPath) - with open(skelPath, "r", encoding="utf8") as skelFile: - skelContent = skelFile.read() - res = Result_generateXliff() - print( - f"Generating xliff file {prettyPathString(outputPath)} from {prettyPathString(mdPath)} and {prettyPathString(skelPath)}...", - ) - with contextlib.ExitStack() as stack: - mdFile = stack.enter_context(open(mdPath, "r", encoding="utf8")) - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - fileID = os.path.basename(mdPath) - mdUri = getRawGithubURLForPath(mdPath) - print(f"Including Github raw URL: {mdUri}") - outputFile.write( - '\n' - f'\n' - f'\n', - ) - outputFile.write(f"\n{xmlEscape(skelContent)}\n\n") - res.numTranslatableStrings = 0 - for lineNo, (mdLine, skelLine) in enumerate( - zip_longest( - preprocessMarkdownLines(mdFile.readlines()), - skelContent.splitlines(keepends=True), - ), - start=1, - ): - mdLine = mdLine.rstrip() - skelLine = skelLine.rstrip() - if m := re_translationID.match(skelLine): - res.numTranslatableStrings += 1 - prefix, ID, suffix = m.groups() - if prefix and not mdLine.startswith(prefix): - raise ValueError( - f'Line {lineNo}: does not start with "{prefix}", {mdLine=}, {skelLine=}', - ) - if suffix and not mdLine.endswith(suffix): - raise ValueError( - f'Line {lineNo}: does not end with "{suffix}", {mdLine=}, {skelLine=}', - ) - source = mdLine[len(prefix) : len(mdLine) - len(suffix)] - outputFile.write( - f'\n\nline: {lineNo + 1}\n', - ) - if prefix: - outputFile.write( - f'prefix: {xmlEscape(prefix)}\n', - ) - if suffix: - outputFile.write( - f'suffix: {xmlEscape(suffix)}\n', - ) - outputFile.write( - "\n" - f"\n" - f"{xmlEscape(source)}\n" - "\n" - "\n", # fmt: skip - ) - else: - if mdLine != skelLine: - raise ValueError( - f"Line {lineNo}: {mdLine=} does not match {skelLine=}", - ) - outputFile.write("\n") - print( - f"Generated xliff file with {res.numTranslatableStrings} translatable strings", - ) - return res - - -@dataclass -class Result_translateXliff: - numTranslatedStrings: int = 0 - - -def updateXliff( - xliffPath: str, - mdPath: str, - outputPath: str, -): - # uses generateMarkdown, extractSkeleton, updateSkeleton, and generateXliff to generate an updated xliff file. - outputDir = os.path.dirname(outputPath) - print( - f"Generating updated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} and {prettyPathString(mdPath)}...", - ) - with contextlib.ExitStack() as stack: - origMdPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="generated_", - suffix=".md", - ), - ) - generateMarkdown(xliffPath=xliffPath, outputPath=origMdPath, translated=False) - origSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="extracted_", - suffix=".skel", - ), - ) - extractSkeleton(xliffPath=xliffPath, outputPath=origSkelPath) - updatedSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="updated_", - suffix=".skel", - ), - ) - updateSkeleton( - origMdPath=origMdPath, - newMdPath=mdPath, - origSkelPath=origSkelPath, - outputPath=updatedSkelPath, - ) - generateXliff( - mdPath=mdPath, - skelPath=updatedSkelPath, - outputPath=outputPath, - ) - print(f"Generated updated xliff file {prettyPathString(outputPath)}") - - -def translateXliff( - xliffPath: str, - lang: str, - pretranslatedMdPath: str, - outputPath: str, - allowBadAnchors: bool = False, -) -> Result_translateXliff: - print( - f"Creating {lang} translated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} using {prettyPathString(pretranslatedMdPath)}...", - ) - res = Result_translateXliff() - with contextlib.ExitStack() as stack: - pretranslatedMdFile = stack.enter_context( - open(pretranslatedMdPath, "r", encoding="utf8"), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - xliffRoot.set("trgLang", lang) - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNo, (skelLine, pretranslatedLine) in enumerate( - zip_longest( - skeletonContent.splitlines(), - preprocessMarkdownLines(pretranslatedMdFile.readlines()), - ), - start=1, - ): - skelLine = skelLine.rstrip() - pretranslatedLine = pretranslatedLine.rstrip() - if m := re_translationID.match(skelLine): - prefix, ID, suffix = m.groups() - if prefix and not pretranslatedLine.startswith(prefix): - raise ValueError( - f'Line {lineNo} of translation does not start with "{prefix}", {pretranslatedLine=}, {skelLine=}', - ) - if suffix and not pretranslatedLine.endswith(suffix): - if allowBadAnchors and (m := re_heading.match(pretranslatedLine)): - print( - f"Warning: ignoring bad anchor in line {lineNo}: {pretranslatedLine}", - ) - suffix = m.group(3) - if suffix and not pretranslatedLine.endswith(suffix): - raise ValueError( - f'Line {lineNo} of translation: does not end with "{suffix}", {pretranslatedLine=}, {skelLine=}', - ) - translation = pretranslatedLine[len(prefix) : len(pretranslatedLine) - len(suffix)] - try: - unit = xliffRoot.find( - f'./xliff:file/xliff:unit[@id="{ID}"]', - namespaces=namespace, - ) - if unit is not None: - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is not None: - target = lxml.etree.Element("target") - target.text = translation - target.tail = "\n" - segment.append(target) - res.numTranslatedStrings += 1 - else: - raise ValueError(f"No segment found for unit {ID}") - else: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - except Exception as e: - e.add_note(f"Line {lineNo}: {pretranslatedLine=}, {skelLine=}") - raise - elif skelLine != pretranslatedLine: - raise ValueError( - f"Line {lineNo}: pretranslated line {pretranslatedLine!r}, does not match skeleton line {skelLine!r}", - ) - xliff.write(outputPath, encoding="utf8", xml_declaration=True) - print( - f"Translated xliff file with {res.numTranslatedStrings} translated strings", - ) - return res - - -@dataclass -class Result_generateMarkdown: - numTotalLines = 0 - numTranslatableStrings = 0 - numTranslatedStrings = 0 - numBadTranslationStrings = 0 - - -def generateMarkdown( - xliffPath: str, - outputPath: str, - translated: bool = True, -) -> Result_generateMarkdown: - print( - f"Generating markdown file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)}...", - ) - res = Result_generateMarkdown() - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNum, line in enumerate(skeletonContent.splitlines(keepends=True), 1): - res.numTotalLines += 1 - if m := re_translationID.match(line): - prefix, ID, suffix = m.groups() - res.numTranslatableStrings += 1 - unit = xliffRoot.find( - f'./xliff:file/xliff:unit[@id="{ID}"]', - namespaces=namespace, - ) - if unit is None: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - raise ValueError(f"No segment found for unit {ID}") - source = segment.find("./xliff:source", namespaces=namespace) - if source is None: - raise ValueError(f"No source found for unit {ID}") - translation = "" - if translated: - target = segment.find("./xliff:target", namespaces=namespace) - if target is not None: - targetText = target.text - if targetText: - translation = targetText - # Crowdin treats empty targets () as a literal translation. - # Filter out such strings and count them as bad translations. - if translation in ( - "", - "<target/>", - "", - "<target></target>", - ): - res.numBadTranslationStrings += 1 - translation = "" - else: - res.numTranslatedStrings += 1 - # If we have no translation, use the source text - if not translation: - sourceText = source.text - if sourceText is None: - raise ValueError(f"No source text found for unit {ID}") - translation = sourceText - outputFile.write(f"{prefix}{translation}{suffix}\n") - else: - outputFile.write(line) - print( - f"Generated markdown file with {res.numTotalLines} total lines, {res.numTranslatableStrings} translatable strings, and {res.numTranslatedStrings} translated strings. Ignoring {res.numBadTranslationStrings} bad translated strings", - ) - return res - - -def ensureMarkdownFilesMatch(path1: str, path2: str, allowBadAnchors: bool = False): - print( - f"Ensuring files {prettyPathString(path1)} and {prettyPathString(path2)} match...", - ) - with contextlib.ExitStack() as stack: - file1 = stack.enter_context(open(path1, "r", encoding="utf8")) - file2 = stack.enter_context(open(path2, "r", encoding="utf8")) - for lineNo, (line1, line2) in enumerate( - zip_longest( - preprocessMarkdownLines(file1.readlines()), - preprocessMarkdownLines(file2.readlines()), - ), - start=1, - ): - line1 = line1.rstrip() - line2 = line2.rstrip() - if line1 != line2: - if ( - re_postTableHeaderLine.match(line1) - and re_postTableHeaderLine.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of post table header line at line {lineNo}: {line1}, {line2}", - ) - continue - if ( - re_hiddenHeaderRow.match(line1) - and re_hiddenHeaderRow.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of hidden header row at line {lineNo}: {line1}, {line2}", - ) - continue - if allowBadAnchors and (m1 := re_heading.match(line1)) and (m2 := re_heading.match(line2)): - print( - f"Warning: ignoring bad anchor in headings at line {lineNo}: {line1}, {line2}", - ) - line1 = m1.group(1) + m1.group(2) - line2 = m2.group(1) + m2.group(2) - if line1 != line2: - raise ValueError( - f"Files do not match at line {lineNo}: {line1=} {line2=}", - ) - print("Files match") - - -def markdownTranslateCommand(command: str, *args): - print(f"Running markdownTranslate command: {command} {' '.join(args)}") - subprocess.run(["python", __file__, command, *args], check=True) - - -def pretranslateAllPossibleLanguages(langsDir: str, mdBaseName: str): - # This function walks through all language directories in the given directory, skipping en (English) and translates the English xlif and skel file along with the lang's pretranslated md file - enXliffPath = os.path.join(langsDir, "en", f"{mdBaseName}.xliff") - if not os.path.exists(enXliffPath): - raise ValueError(f"English xliff file {enXliffPath} does not exist") - allLangs = set() - succeededLangs = set() - skippedLangs = set() - for langDir in os.listdir(langsDir): - if langDir == "en": - continue - langDirPath = os.path.join(langsDir, langDir) - if not os.path.isdir(langDirPath): - continue - langPretranslatedMdPath = os.path.join(langDirPath, f"{mdBaseName}.md") - if not os.path.exists(langPretranslatedMdPath): - continue - allLangs.add(langDir) - langXliffPath = os.path.join(langDirPath, f"{mdBaseName}.xliff") - if os.path.exists(langXliffPath): - print(f"Skipping {langDir} as the xliff file already exists") - skippedLangs.add(langDir) - continue - try: - translateXliff( - xliffPath=enXliffPath, - lang=langDir, - pretranslatedMdPath=langPretranslatedMdPath, - outputPath=langXliffPath, - allowBadAnchors=True, - ) - except Exception as e: - print(f"Failed to translate {langDir}: {e}") - continue - rebuiltLangMdPath = os.path.join(langDirPath, f"rebuilt_{mdBaseName}.md") - try: - generateMarkdown( - xliffPath=langXliffPath, - outputPath=rebuiltLangMdPath, - ) - except Exception as e: - print(f"Failed to rebuild {langDir} markdown: {e}") - os.remove(langXliffPath) - continue - try: - ensureMarkdownFilesMatch( - rebuiltLangMdPath, - langPretranslatedMdPath, - allowBadAnchors=True, - ) - except Exception as e: - print( - f"Rebuilt {langDir} markdown does not match pretranslated markdown: {e}", - ) - os.remove(langXliffPath) - continue - os.remove(rebuiltLangMdPath) - print(f"Successfully pretranslated {langDir}") - succeededLangs.add(langDir) - if len(skippedLangs) > 0: - print(f"Skipped {len(skippedLangs)} languages already pretranslated.") - print( - f"Pretranslated {len(succeededLangs)} out of {len(allLangs) - len(skippedLangs)} languages.", - ) - - -if __name__ == "__main__": - mainParser = argparse.ArgumentParser() - commandParser = mainParser.add_subparsers( - title="commands", - dest="command", - required=True, - ) - generateXliffParser = commandParser.add_parser("generateXliff") - generateXliffParser.add_argument( - "-m", - "--markdown", - dest="md", - type=str, - required=True, - help="The markdown file to generate the xliff file for", - ) - generateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the xliff file to", - ) - updateXliffParser = commandParser.add_parser("updateXliff") - updateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The original xliff file", - ) - updateXliffParser.add_argument( - "-m", - "--newMarkdown", - dest="md", - type=str, - required=True, - help="The new markdown file", - ) - updateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the updated xliff to", - ) - translateXliffParser = commandParser.add_parser("translateXliff") - translateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to translate", - ) - translateXliffParser.add_argument( - "-l", - "--lang", - dest="lang", - type=str, - required=True, - help="The language to translate to", - ) - translateXliffParser.add_argument( - "-p", - "--pretranslatedMarkdown", - dest="pretranslatedMd", - type=str, - required=True, - help="The pretranslated markdown file to use", - ) - translateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the translated xliff file to", - ) - generateMarkdownParser = commandParser.add_parser("generateMarkdown") - generateMarkdownParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to generate the markdown file for", - ) - generateMarkdownParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the markdown file to", - ) - generateMarkdownParser.add_argument( - "-u", - "--untranslated", - dest="translated", - action="store_false", - help="Generate the markdown file with the untranslated strings", - ) - ensureMarkdownFilesMatchParser = commandParser.add_parser( - "ensureMarkdownFilesMatch", - ) - ensureMarkdownFilesMatchParser.add_argument( - dest="path1", - type=str, - help="The first markdown file", - ) - ensureMarkdownFilesMatchParser.add_argument( - dest="path2", - type=str, - help="The second markdown file", - ) - pretranslateLangsParser = commandParser.add_parser("pretranslateLangs") - pretranslateLangsParser.add_argument( - "-d", - "--langs-dir", - dest="langsDir", - type=str, - required=True, - help="The directory containing the language directories", - ) - pretranslateLangsParser.add_argument( - "-b", - "--md-base-name", - dest="mdBaseName", - type=str, - required=True, - help="The base name of the markdown files to pretranslate", - ) - args = mainParser.parse_args() - match args.command: - case "generateXliff": - generateXliff(mdPath=args.md, outputPath=args.output) - case "updateXliff": - updateXliff( - xliffPath=args.xliff, - mdPath=args.md, - outputPath=args.output, - ) - case "generateMarkdown": - generateMarkdown( - xliffPath=args.xliff, - outputPath=args.output, - translated=args.translated, - ) - case "translateXliff": - translateXliff( - xliffPath=args.xliff, - lang=args.lang, - pretranslatedMdPath=args.pretranslatedMd, - outputPath=args.output, - ) - case "pretranslateLangs": - pretranslateAllPossibleLanguages( - langsDir=args.langsDir, - mdBaseName=args.mdBaseName, - ) - case "ensureMarkdownFilesMatch": - ensureMarkdownFilesMatch(path1=args.path1, path2=args.path2) - case _: - raise ValueError(f"Unknown command: {args.command}") From 5945f6d2d65a8fc38d33517411e1168b2c48589e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 5 Jun 2026 05:02:04 +0200 Subject: [PATCH 135/142] Support defining different percentages to accept translations --- .github/scripts/checkTranslation.py | 3 --- .github/scripts/crowdinSync.ps1 | 2 +- .github/workflows/crowdinL10n.yml | 1 + 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 346a9ef..b109bef 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -139,9 +139,6 @@ def main(): # Default to poScore for .po and other localization files. print(f"poScore={score}") - # Exit with success (0) if there is at least 50% translated content. - sys.exit(0 if score > 0.5 else 1) - if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 6047207..a72bdbf 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -140,7 +140,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "DEBUG: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff" - $threshold = 0.5 + $threshold = $env:MIN_PERCENTAGE_TRANSLATED $docImported = $false if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 24853d5..89c2ea4 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -15,6 +15,7 @@ env: GH_TOKEN: ${{ github.token }} CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || 'addon' }} + MIN_PERCENTAGE_TRANSLATED: ${{ vars.MIN_PERCENTAGE_TRANSLATED || 50 }} jobs: crowdinSync: From 55c1a68eeebade39a179597779a122d5db252eb2 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 6 Jun 2026 23:30:05 +0200 Subject: [PATCH 136/142] Refine Crowdin synchronization workflow - Prefer XLIFF documentation translations. - Unify translation threshold validation. - Fix score comparison logic. - Return raw percentages from getScoreFromApi. --- .github/scripts/checkTranslation.py | 2 +- .github/scripts/crowdinSync.ps1 | 124 +++++++++++++++++----------- 2 files changed, 79 insertions(+), 47 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index b109bef..9d03dc6 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -100,7 +100,7 @@ def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: # Also handles underscore to dash conversion for Crowdin compatibility if langApi.lower().startswith(langId.lower().replace("_", "-")): progress = float(item["data"]["translationProgress"]) - return progress / 100 + return progress # Check pagination total. total = resp["pagination"]["totalCount"] diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index a72bdbf..fa9febd 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -24,12 +24,14 @@ if (Test-Path $mdFile) { Copy-Item "$addonId.xliff" $tempXliff -Force Write-Host "DEBUG: Updating XLIFF source based on readme.md..." ./l10nUtil.exe md2xliff $mdFile $xliffFile -o $tempXliff - } finally { + } + finally { if (Test-Path $tempXliff) { Remove-Item $tempXliff -Force } } - } else { + } + else { Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." ./l10nUtil.exe md2xliff $mdFile $xliffFile } @@ -49,8 +51,10 @@ if (Test-Path $potFile) { if (Test-Path $xliffFile) { Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$xliffFile" -c $env:L10N_UTIL_CONFIG + git add "$xliffFile" git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { git commit -m "Update $xliffFile for $addonId" } @@ -69,127 +73,155 @@ New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + $langCode = $dir.Name - if ($langCode -eq "en") { continue } + if ($langCode -eq "en") { + continue + } # --- Identify codes + $crowdinLang = $null - # Use the ."variable" syntax to correctly read the PSCustomObject from JSON if ($languageMappings.PSObject.Properties.Name -contains $langCode) { $crowdinLang = $languageMappings."$langCode" } - # Fallback: If no mapping is found, replace underscores with dashes for Crowdin compatibility if (-not $crowdinLang) { $crowdinLang = $langCode.Replace('_', '-') } - # The $langCode (folder name from Crowdin) represents the local repository language code. - # It matches the NVDA directory structure, so no extra mapping is needed. Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan # Paths - $remoteMd = Join-Path $dir.FullName "$addonId.md" + $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" $remotePo = Join-Path $dir.FullName "$addonId.po" + $localMdDir = "addon/doc/$langCode" $localMd = "$localMdDir/readme.md" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" # --- 3.1 PO FILE PROCESSING --- $poImported = $false + $scorePo = 0.0 + $threshold = $env:MIN_PERCENTAGE_TRANSLATED + if (Test-Path $remotePo) { - Write-Host "DEBUG: Checking Remote PO progress for $crowdinLang..." - uv run python .github/scripts/checkTranslation.py "$addonId.po" $crowdinLang - if ($LASTEXITCODE -eq 0) { - Write-Host "SUCCESS: Remote PO is valid. Importing to $localPoPath" + + Write-Host "DEBUG: Evaluating Remote PO score..." + + $res = uv run python .github/scripts/checkTranslation.py "$addonId.po" $crowdinLang + + $scorePo = [double]( + ($res | Select-String "poScore=").ToString().Split("=")[1] + ) + + Write-Host "DEBUG: PO Score -> $scorePo" + + if ($scorePo -ge $threshold) { + + Write-Host "SUCCESS: Remote PO is above threshold. Importing to $localPoPath" + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + Move-Item $remotePo $localPoPath -Force + $poImported = $true - } else { - Write-Host "WARNING: Remote PO progress is below threshold." + } + else { + + Write-Host "WARNING: Remote PO score is below threshold ($threshold)." } } if (-not $poImported -and (Test-Path $localPoPath)) { + Write-Host "ACTION: Uploading local legacy PO to Crowdin ($crowdinLang) as fallback." + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.po" $localPoPath -c $env:L10N_UTIL_CONFIG } - # --- 3.2 DOCUMENTATION PROCESSING (MD & XLIFF) --- - $scoreMd = 0.0 - $scoreXliff = 0.0 + # --- 3.2 DOCUMENTATION PROCESSING (XLIFF ONLY) --- - if (Test-Path $remoteMd) { - Write-Host "DEBUG: Evaluating Remote Markdown score..." - $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $crowdinLang - $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] - } else { - Write-Host "DEBUG: No remote Markdown file found for this language." - } + $scoreXliff = 0.0 if (Test-Path $remoteXliff) { + Write-Host "DEBUG: Evaluating Remote XLIFF score..." + $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $crowdinLang - $scoreXliff = [double]($res | Select-String "xliffScore=").ToString().Split("=")[1] - } else { + + $scoreXliff = [double]( + ($res | Select-String "xliffScore=").ToString().Split("=")[1] + ) + } + else { Write-Host "DEBUG: No remote XLIFF file found for this language." } - Write-Host "DEBUG: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + Write-Host "DEBUG: XLIFF Score -> $scoreXliff" $threshold = $env:MIN_PERCENTAGE_TRANSLATED $docImported = $false - if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { - if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } - - if ($scoreXliff -ge $scoreMd) { - Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($langCode)..." - ./l10nUtil.exe xliff2md $remoteXliff $localMd - $docImported = $true - } else { - Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($langCode)..." - Move-Item $remoteMd $localMd -Force - $docImported = $true + if ($scoreXliff -ge $threshold) { + + if (!(Test-Path $localMdDir)) { + New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } - } else { - Write-Host "WARNING: Both remote MD and XLIFF scores are below threshold ($threshold)." + + Write-Host "SUCCESS: Importing documentation from XLIFF ($langCode)..." + + ./l10nUtil.exe xliff2md $remoteXliff $localMd + + $docImported = $true } + else { - if (-not $docImported -and (Test-Path $localMd)) { - Write-Host "ACTION: Documentation quality too low. Uploading local MD to Crowdin ($crowdinLang) as fallback." - ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c $env:L10N_UTIL_CONFIG + Write-Host "WARNING: Remote XLIFF score is below threshold ($threshold)." } + + # No Markdown fallback upload anymore. + # XLIFF is now the single translation source in Crowdin. } # --- STEP 4: COMMIT UPDATED TRANSLATIONS --- git add addon/locale addon/doc + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin (Automatic Sync)" + Write-Host "SUCCESS: Translations committed." -} else { +} +else { + Write-Host "DEBUG: No changes in translations to commit." } # Push all generated commits after successful Crowdin synchronization + $pushOutput = git push 2>&1 -# Get the full repository name in "owner/repository" format (e.g., hkatic/clock) $repository = $env:GITHUB_REPOSITORY Write-Host $pushOutput if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to push commits to $repository." } elseif ($pushOutput -match "Everything up-to-date") { + Write-Host "INFO: No new commits needed to be pushed." } else { + Write-Host "SUCCESS: New commits successfully pushed to $repository." -} +} \ No newline at end of file From f38edb52083e7c78be11e9d598f9a04a2fc9d06d Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 6 Jun 2026 23:32:13 +0200 Subject: [PATCH 137/142] Update localization workflow documentation Document XLIFF-based documentation synchronization, translation threshold configuration, GitHub Actions variables, and related Crowdin workflow changes. --- readme.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 7ddcc5a..acf46c2 100644 --- a/readme.md +++ b/readme.md @@ -179,6 +179,16 @@ If not, leave the dictionary empty. This template allows you to automate the synchronization of documentation and interface messages with Crowdin. +#### Documentation Translation Format + +Documentation translations are managed through XLIFF files in Crowdin. + +The synchronization workflow generates an XLIFF source file from the English `readme.md` documentation using `l10nUtil.exe md2xliff`. + +Translators work on the XLIFF file in Crowdin. During synchronization, translated XLIFF files are downloaded and converted back to Markdown using `l10nUtil.exe xliff2md`. + +XLIFF is considered the single source of truth for documentation translations. Legacy Markdown translation files may still exist on Crowdin, but they are ignored by the synchronization workflow. + #### 1. Crowdin Project Setup You need a Crowdin account and an API token with permissions to manage a project. @@ -187,20 +197,48 @@ If you wish to use the community project [Crowdin project to translate NVDA add- * **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**), or in the [NVDA Add-ons Mailing List](https://groups.io/g/nvda-addons) (**nvda-addons@groups.io**), requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. -#### 2. GitHub Secrets -To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): +#### 2. GitHub Secrets and Variables + +To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): + * `CROWDIN_TOKEN`: Paste your Crowdin API token here. Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), please add the following variables: * `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. -* L10N_UTIL_CONFIG: The path to the yaml file containing the configuration for the l10nUtil.exe file, used by the translation scripts. -For more details, visit the [nvdaL10n repositori](https://github.com/nvaccess/nvdaL10n). +* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. + +For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). + +The workflow also supports a variable named `MIN_PERCENTAGE_TRANSLATED`, which defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. + +To create this variable: + +1. Open your GitHub repository. +2. Go to **Settings** > **Secrets and variables** > **Actions**. +3. Select the **Variables** tab. +4. Click **New repository variable**. +5. Create a variable named `MIN_PERCENTAGE_TRANSLATED`. +6. Set its value to a number between `0` and `100`. + +Examples: + +* `50`: Import files that are at least 50% translated. +* `75`: Import files that are at least 75% translated. +* `100`: Import only fully translated files. + +If this variable is not defined, the workflow uses a default value of `50`. #### 3. Infrastructure + Ensure that your repository includes the following files (provided in this template): + * **Workflows:** `.github/workflows/crowdinL10n.yml` -* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `markdownTranslate.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. + +Documentation synchronization relies on the XLIFF support built into `l10nUtil.exe`. + +The `md2xliff` command is used to generate the source XLIFF file from the English `readme.md` documentation file. Translated XLIFF files downloaded from Crowdin are then converted back to Markdown documentation using `l10nUtil.exe xliff2md`. #### 4. Running the Workflow @@ -208,6 +246,10 @@ The translation workflow will be run weekly. Also, you can run the workflow manu If you have various add-ons, please edit the cron line of workflows in each repo, so that your API token is not used at the same time. +Documentation and interface translations are synchronized only when their translation percentage reaches the configured `MIN_PERCENTAGE_TRANSLATED` threshold. + +This validation mechanism is applied consistently to both `.po` and `.xliff` translation files. + ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From 6e9652204c6fa062ad57c9790584d59587d15443 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 7 Jun 2026 13:33:45 +0200 Subject: [PATCH 138/142] Clarify repository variable configuration and workflow scheduling in README --- readme.md | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/readme.md b/readme.md index acf46c2..4f43433 100644 --- a/readme.md +++ b/readme.md @@ -203,31 +203,21 @@ To allow the workflows to communicate with Crowdin, you must add the following s * `CROWDIN_TOKEN`: Paste your Crowdin API token here. -Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), please add the following variables: +Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), you can create repository variables from **Settings > Secrets and variables > Actions > Variables** by selecting the **Variables** tab and clicking **New repository variable**. -* `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. -* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. - -For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). - -The workflow also supports a variable named `MIN_PERCENTAGE_TRANSLATED`, which defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. +The following repository variables are supported: -To create this variable: - -1. Open your GitHub repository. -2. Go to **Settings** > **Secrets and variables** > **Actions**. -3. Select the **Variables** tab. -4. Click **New repository variable**. -5. Create a variable named `MIN_PERCENTAGE_TRANSLATED`. -6. Set its value to a number between `0` and `100`. +* `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. +* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). +* `MIN_PERCENTAGE_TRANSLATED`: Defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. The value must be between `0` and `100`. -Examples: +Examples for `MIN_PERCENTAGE_TRANSLATED`: * `50`: Import files that are at least 50% translated. * `75`: Import files that are at least 75% translated. * `100`: Import only fully translated files. -If this variable is not defined, the workflow uses a default value of `50`. +If `MIN_PERCENTAGE_TRANSLATED` is not defined, the workflow uses a default value of `50`. #### 3. Infrastructure @@ -244,7 +234,7 @@ The `md2xliff` command is used to generate the source XLIFF file from the Englis The translation workflow will be run weekly. Also, you can run the workflow manually from GitHub or using GitHub CLI. -If you have various add-ons, please edit the cron line of workflows in each repo, so that your API token is not used at the same time. +If you manage several add-ons, consider using different cron schedules for each repository. Although the workflow includes a random startup delay to reduce collisions, concurrent Crowdin synchronization jobs may still occur. Documentation and interface translations are synchronized only when their translation percentage reaches the configured `MIN_PERCENTAGE_TRANSLATED` threshold. From e857ea31c264700831de7cbe69aadb1c727ffcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 9 Jun 2026 07:35:20 +0200 Subject: [PATCH 139/142] Fix spaces --- .github/scripts/crowdinSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index fa9febd..8e7bb92 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -224,4 +224,4 @@ elseif ($pushOutput -match "Everything up-to-date") { else { Write-Host "SUCCESS: New commits successfully pushed to $repository." -} \ No newline at end of file +} From 9a4b9ebfd232ba4e65ae465fe5e00af6673ad5ee Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 10 Jun 2026 11:14:43 +1000 Subject: [PATCH 140/142] fixup readme spacing Co-authored-by: Sean Budd --- readme.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 6008767..88f2dda 100644 --- a/readme.md +++ b/readme.md @@ -188,9 +188,11 @@ Documentation translations are managed through XLIFF files in Crowdin. The synchronization workflow generates an XLIFF source file from the English `readme.md` documentation using `l10nUtil.exe md2xliff`. -Translators work on the XLIFF file in Crowdin. During synchronization, translated XLIFF files are downloaded and converted back to Markdown using `l10nUtil.exe xliff2md`. +Translators work on the XLIFF file in Crowdin. +During synchronization, translated XLIFF files are downloaded and converted back to Markdown using `l10nUtil.exe xliff2md`. -XLIFF is considered the single source of truth for documentation translations. Legacy Markdown translation files may still exist on Crowdin, but they are ignored by the synchronization workflow. +XLIFF is considered the single source of truth for documentation translations. +Legacy Markdown translation files may still exist on Crowdin, but they are ignored by the synchronization workflow. #### 1. Crowdin Project Setup @@ -231,11 +233,13 @@ Ensure that your repository includes the following files (provided in this templ Documentation synchronization relies on the XLIFF support built into `l10nUtil.exe`. -The `md2xliff` command is used to generate the source XLIFF file from the English `readme.md` documentation file. Translated XLIFF files downloaded from Crowdin are then converted back to Markdown documentation using `l10nUtil.exe xliff2md`. +The `md2xliff` command is used to generate the source XLIFF file from the English `readme.md` documentation file. +Translated XLIFF files downloaded from Crowdin are then converted back to Markdown documentation using `l10nUtil.exe xliff2md`. #### 4. Running the Workflow -The translation workflow will be run weekly. Also, you can run the workflow manually from GitHub or using GitHub CLI. +The translation workflow will be run weekly. +Also, you can run the workflow manually from GitHub or using GitHub CLI. If you manage several add-ons, consider using different cron schedules for each repository. Although the workflow includes a random startup delay to reduce collisions, concurrent Crowdin synchronization jobs may still occur. From a2df4065e6f151359a7243dcd254711169feeb8e Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 10 Jun 2026 11:16:27 +1000 Subject: [PATCH 141/142] Update .github/workflows/crowdinL10n.yml Co-authored-by: WMHN <1872265132@qq.com> --- .github/workflows/crowdinL10n.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 89c2ea4..6045d44 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -57,6 +57,7 @@ jobs: run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - name: Download translations from Crowdin + if: ${{ env.crowdinAuthToken }} shell: pwsh env: ADDON_ID: ${{ steps.getAddonInfo.outputs.addonId }} From 9bd75e99f4fd68d3f6268432d5d4cb40fe3e3baf Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 10 Jun 2026 11:16:47 +1000 Subject: [PATCH 142/142] Update readme.md --- readme.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 88f2dda..8967772 100644 --- a/readme.md +++ b/readme.md @@ -213,8 +213,10 @@ Optionally, if you don't want to use the [Crowdin community project](https://cro The following repository variables are supported: * `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. -* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). -* `MIN_PERCENTAGE_TRANSLATED`: Defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. The value must be between `0` and `100`. +* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. +For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). +* `MIN_PERCENTAGE_TRANSLATED`: Defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. +The value must be between `0` and `100`. Examples for `MIN_PERCENTAGE_TRANSLATED`: