diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml new file mode 100644 index 0000000..5f75489 --- /dev/null +++ b/.github/workflows/crowdinL10n.yml @@ -0,0 +1,116 @@ +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' +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 + permissions: + contents: write + steps: + - name: Checkout add-on + uses: actions/checkout@v6 + with: + submodules: true + - name: "Set up Python" + 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 }} + 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 }} + 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 }} + 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 + 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 + - 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 + 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 a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py new file mode 100644 index 0000000..d8cce3b --- /dev/null +++ b/.github/workflows/setOutputs.py @@ -0,0 +1,59 @@ +# 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 +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") + + +if __name__ == "__main__": + main() diff --git a/.gitignore b/.gitignore index 0be8af1..e915e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,26 @@ +# 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 + +# act configuration +.actrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 207177d..ea70058 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: diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c45fe3 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.11 diff --git a/buildVars.py b/buildVars.py index c125fae..a3fe862 100644 --- a/buildVars.py +++ b/buildVars.py @@ -10,7 +10,6 @@ # which returns whatever is given to it as an argument. from site_scons.site_tools.NVDATool.utils import _ - # Add-on information variables addon_info = AddonInfo( # add-on Name/identifier, internal for NVDA diff --git a/readme.md b/readme.md index 9ad847a..788eeef 100644 --- a/readme.md +++ b/readme.md @@ -156,6 +156,17 @@ 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: 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()