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
147 changes: 147 additions & 0 deletions .github/scripts/checkTranslation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# 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 / 100

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

# 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()
180 changes: 180 additions & 0 deletions .github/scripts/crowdinSync.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/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..."
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
}
}

# 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"
git push
}
}

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

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

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

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."
}

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: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff"

$threshold = 0.5
$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
}
} else {
Write-Host "WARNING: Both remote MD and XLIFF scores are below threshold ($threshold)."
}

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

# --- 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)"
$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."
}
14 changes: 14 additions & 0 deletions .github/scripts/languageMappings.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading