Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/scripts/checkTranslation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# 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, 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).
: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=projectId,
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"{baseTarget}{searchExt}"):
fileId = f["data"]["id"]
print(f"DEBUG: Match found: {path_crowdin} (ID: {fileId})")
return fileId

if len(data) < limit:
break

offset += limit

return None


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.
"""
token = os.environ.get("crowdinAuthToken")
projectIdEnv = os.environ.get("CROWDIN_PROJECT_ID")

if not token or not projectIdEnv:
print("ERROR: Missing environment variables 'crowdinAuthToken' or 'CROWDIN_PROJECT_ID'.")
return 0.0

client = CrowdinClient(token=token)
projectId = int(projectIdEnv)

try:
# 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.
searchExt = ".pot" if extTarget == "po" else f".{extTarget}"

print(f"DEBUG: Searching for source file: {baseTarget}{searchExt}")

fileId = findFileId(client, projectId, baseTarget, searchExt)

if fileId is None:
print(f"WARNING: File '{baseTarget}{searchExt}' not found on Crowdin.")
return 0.0

# Pagination for translation status (Progress).
offset = 0
limit = 100

while True:
resp = client.translation_status.get_file_progress(
projectId=projectId,
fileId=fileId,
limit=limit,
offset=offset,
)

data = resp["data"]
for item in data:
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
if langApi.lower().startswith(langId.lower().replace("_", "-")):
progress = float(item["data"]["translationProgress"])
return progress

# Check pagination total.
total = resp["pagination"]["totalCount"]
if offset + limit >= total:
break
offset += limit

print(f"DEBUG: Language '{langId}' not found in progress list for this file.")
return 0.0

except Exception as e:
print(f"API ERROR: {e}")
return 0.0


def main():
if len(sys.argv) < 3:
print("Usage: python checkTranslation.py <filePath> <langId>")
sys.exit(2)

input_file = sys.argv[1]
lang = sys.argv[2]

score = getScoreFromApi(input_file, lang)

# Output formatted for capture by the PowerShell script.
print(f"translationRatio={score}")

# 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}")


if __name__ == "__main__":
main()
227 changes: 227 additions & 0 deletions .github/scripts/crowdinSync.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
#!/usr/bin/env pwsh
$ErrorActionPreference = 'Stop'

# Git configuration for automated commits
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

$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 ---

$xliffFile = "./$addonId.xliff"
$mdFile = "./readme.md"

if (Test-Path $mdFile) {
if (Test-Path $xliffFile) {
$tempXliff = [System.IO.Path]::GetTempFileName()
try {
Copy-Item "$addonId.xliff" $tempXliff -Force
Write-Host "DEBUG: Updating XLIFF source based on readme.md..."
./l10nUtil.exe md2xliff $mdFile $xliffFile -o $tempXliff
}
finally {
if (Test-Path $tempXliff) {
Remove-Item $tempXliff -Force
}
}
}
else {
Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..."
./l10nUtil.exe md2xliff $mdFile $xliffFile
}
}

# Update POT file (addon interface)
uv run scons pot
$potFile = "$addonId.pot"

# --- STEP 2: UPLOAD SOURCES TO CROWDIN ---

if (Test-Path $potFile) {
Write-Host "DEBUG: Uploading updated POT source to Crowdin..."
./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 $env:L10N_UTIL_CONFIG

git add "$xliffFile"
git diff --staged --quiet

if ($LASTEXITCODE -ne 0) {
git commit -m "Update $xliffFile for $addonId"
}
}

# --- STEP 3: EXPORT AND PROCESS TRANSLATIONS ---

Write-Host "DEBUG: Exporting translations from Crowdin..."
./l10nUtil.exe exportTranslations -o _addonL10n -c $env:L10N_UTIL_CONFIG

# 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

if ($langCode -eq "en") {
continue
}

# --- Identify codes

$crowdinLang = $null

if ($languageMappings.PSObject.Properties.Name -contains $langCode) {
$crowdinLang = $languageMappings."$langCode"
}

if (-not $crowdinLang) {
$crowdinLang = $langCode.Replace('_', '-')
}

Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan

# Paths

$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: 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 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 (XLIFF ONLY) ---

$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 {
Write-Host "DEBUG: No remote XLIFF file found for this language."
}

Write-Host "DEBUG: XLIFF Score -> $scoreXliff"

$threshold = $env:MIN_PERCENTAGE_TRANSLATED
$docImported = $false

if ($scoreXliff -ge $threshold) {

if (!(Test-Path $localMdDir)) {
New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null
}

Write-Host "SUCCESS: Importing documentation from XLIFF ($langCode)..."

./l10nUtil.exe xliff2md $remoteXliff $localMd

$docImported = $true
}
else {

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 {

Write-Host "DEBUG: No changes in translations to commit."
}

# Push all generated commits after successful Crowdin synchronization

$pushOutput = git push 2>&1

$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."
}
15 changes: 15 additions & 0 deletions .github/scripts/languageMappings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"af_ZA": "af",
"de_CH": "de-CH",
"es": "es-ES",
"es_CO": "es-CO",
"nb_NO": "nb",
"ne": "ne-NP",
"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"
}
Loading