Skip to content

Commit 31cd13c

Browse files
committed
Enhance Crowdin l10n workflow with MD quality evaluation and comparison logic
- 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
1 parent 5bee363 commit 31cd13c

14 files changed

Lines changed: 492 additions & 435 deletions

File tree

.github/scripts/checkTranslation.py

Lines changed: 158 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,105 +2,198 @@
22
import os
33
import xml.etree.ElementTree as ET
44
import polib
5+
import langid
56

7+
def normalize(s: str | None) -> str:
8+
return " ".join((s or "").strip().lower().split())
69

7-
def normalize(s: str) -> str:
8-
# Normalize strings for reliable comparison (trim, lowercase, collapse spaces)
9-
return " ".join((s or "").strip().lower().split())
10+
# -----------------------------
1011

12+
# PO CHECK
1113

1214
# -----------------------------
13-
# PO FILE CHECK
14-
# -----------------------------
15-
def checkPo(path: str) -> float:
16-
# Parse PO file using polib
17-
po = polib.pofile(path)
1815

19-
translated = 0
20-
total = 0
16+
def checkPo(path: str) -> float:
17+
po = polib.pofile(path)
2118

22-
for entry in po:
23-
# Skip empty msgid entries
24-
if not entry.msgid.strip():
25-
continue
19+
```
20+
translated = 0
21+
total = 0
2622

27-
total += 1
23+
for entry in po:
24+
if not entry.msgid.strip():
25+
continue
2826

29-
# Consider entry translated only if msgstr differs from msgid
30-
if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid):
31-
translated += 1
27+
total += 1
3228

33-
return translated / total if total else 0.0
29+
if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid):
30+
translated += 1
3431

32+
return translated / total if total else 0.0
33+
```
3534

3635
# -----------------------------
37-
# XLIFF CHECK (skeleton-safe generic parsing)
36+
37+
# XLIFF CHECK
38+
3839
# -----------------------------
40+
3941
def checkXliff(path: str) -> float:
40-
# Parse XML XLIFF file
41-
tree = ET.parse(path)
42-
root = tree.getroot()
42+
tree = ET.parse(path)
43+
root = tree.getroot()
44+
45+
```
46+
translated = 0
47+
total = 0
48+
source = None
49+
50+
for elem in root.iter():
51+
if elem.tag.endswith("source"):
52+
source = normalize(elem.text)
53+
54+
elif elem.tag.endswith("target"):
55+
target = normalize(elem.text)
56+
57+
if source:
58+
total += 1
59+
if target and target != source:
60+
translated += 1
4361

44-
translated = 0
45-
total = 0
62+
return translated / total if total else 0.0
63+
```
4664

47-
source = None
65+
# -----------------------------
4866

49-
for elem in root.iter():
67+
# MD LANGUAGE SCORE (langid)
5068

51-
# Capture source segments
52-
if elem.tag.endswith("source"):
53-
source = normalize(elem.text)
69+
# -----------------------------
5470

55-
# Compare with target segments
56-
elif elem.tag.endswith("target"):
57-
target = normalize(elem.text)
71+
def scoreMd(path: str, expected_lang: str) -> float:
72+
try:
73+
with open(path, "r", encoding="utf-8") as f:
74+
text = f.read()
75+
except Exception:
76+
return 0.0
5877

59-
if source:
60-
total += 1
78+
```
79+
if not text.strip():
80+
return 0.0
6181

62-
# Count as translated only if target differs from source
63-
if target and target != source:
64-
translated += 1
82+
lang, score = langid.classify(text)
6583

66-
return translated / total if total else 0.0
84+
# Normalize score into positive confidence
85+
confidence = 1 / (1 + abs(score))
6786

87+
if lang == expected_lang:
88+
return confidence
89+
else:
90+
return 0.0
91+
```
6892

6993
# -----------------------------
70-
# MAIN ENTRY POINT
94+
95+
# COMPARE MULTIPLE MD FILES
96+
7197
# -----------------------------
72-
def main():
73-
if len(sys.argv) < 2:
74-
print("Usage: checkTranslation.py <file>")
75-
sys.exit(2)
7698

77-
path = sys.argv[1]
99+
def compareMd(files: list[str], lang: str):
100+
results = []
78101

79-
if not os.path.exists(path):
80-
print(f"File not found: {path}")
81-
sys.exit(2)
102+
```
103+
for f in files:
104+
if not os.path.exists(f):
105+
continue
82106

83-
ext = os.path.splitext(path)[1].lower()
107+
score = scoreMd(f, lang)
108+
results.append((f, score))
84109

85-
# Dispatch based on file type
86-
if ext == ".po":
87-
ratio = checkPo(path)
110+
if not results:
111+
print("winner=None")
112+
sys.exit(1)
88113

89-
elif ext in [".xliff", ".xlf"]:
90-
ratio = checkXliff(path)
114+
results.sort(key=lambda x: x[1], reverse=True)
91115

92-
else:
93-
print(f"Unsupported file type: {ext}")
94-
sys.exit(2)
116+
winner = results[0]
95117

96-
print(f"translation_ratio={ratio}")
118+
print("comparison_results:")
119+
for f, s in results:
120+
print(f"{f}={s}")
97121

98-
# Threshold: consider file translated if above 5%
99-
if ratio > 0.05:
100-
sys.exit(0) # translated
101-
else:
102-
sys.exit(1) # not translated
122+
print(f"winner={winner[0]}")
123+
print(f"winner_score={winner[1]}")
103124

125+
sys.exit(0)
126+
```
104127

105-
if __name__ == "__main__":
106-
main()
128+
# -----------------------------
129+
130+
# MAIN
131+
132+
# -----------------------------
133+
134+
def main():
135+
if len(sys.argv) < 2:
136+
print("Usage:")
137+
print(" checkTranslation.py <file>")
138+
print(" checkTranslation.py <file> <lang>")
139+
print(" checkTranslation.py <file1> <file2> [...] <lang>")
140+
sys.exit(2)
141+
142+
```
143+
args = sys.argv[1:]
144+
145+
# -------------------------
146+
# MULTI FILE MODE
147+
# -------------------------
148+
if len(args) >= 3:
149+
*files, lang = args
150+
compareMd(files, lang)
151+
return
152+
153+
path = args[0]
154+
155+
if not os.path.exists(path):
156+
print(f"File not found: {path}")
157+
sys.exit(2)
158+
159+
ext = os.path.splitext(path)[1].lower()
160+
161+
# -------------------------
162+
# PO
163+
# -------------------------
164+
if ext == ".po":
165+
ratio = checkPo(path)
166+
print(f"translation_ratio={ratio}")
167+
sys.exit(0 if ratio > 0.05 else 1)
168+
169+
# -------------------------
170+
# XLIFF
171+
# -------------------------
172+
elif ext in [".xliff", ".xlf"]:
173+
ratio = checkXliff(path)
174+
print(f"translation_ratio={ratio}")
175+
sys.exit(0 if ratio > 0.05 else 1)
176+
177+
# -------------------------
178+
# MD (LANG SCORE)
179+
# -------------------------
180+
elif ext == ".md":
181+
182+
if len(args) < 2:
183+
print("Missing language argument for MD scoring")
184+
sys.exit(2)
185+
186+
lang = args[1]
187+
score = scoreMd(path, lang)
188+
189+
print(f"md_score={score}")
190+
191+
sys.exit(0)
192+
193+
else:
194+
print(f"Unsupported file type: {ext}")
195+
sys.exit(2)
196+
```
197+
198+
if **name** == "**main**":
199+
main()

.github/scripts/setOutputs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def main():
1414
name = "addonId"
1515
value = addonId
1616
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
17-
f.write(f"{name}={value}\n")
17+
_ = f.write(f"{name}={value}\n")
1818

1919

2020
if __name__ == "__main__":

.github/workflows/build_addon.yml

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,39 +14,44 @@ on:
1414

1515
jobs:
1616
build:
17+
# Building the add-on template as an add-on does not make sense (and fails).
18+
# Do not modify this repo name with your own one! (should remain the template)
19+
if: github.repository != 'nvaccess/addonTemplate'
1720

1821
runs-on: ubuntu-latest
1922

2023
steps:
21-
- uses: actions/checkout@v6
22-
23-
- run: echo -e "pre-commit\nscons\nmarkdown">requirements.txt
24-
25-
- name: Set up Python
26-
uses: actions/setup-python@v6
24+
- name: Checkout repo
25+
uses: actions/checkout@v6
26+
- name: Install uv
27+
uses: astral-sh/setup-uv@v7
2728
with:
28-
python-version: 3.11
29-
cache: 'pip'
30-
29+
enable-cache: true
3130
- name: Install dependencies
3231
run: |
33-
python -m pip install --upgrade pip wheel
34-
pip install -r requirements.txt
3532
sudo apt-get update -y
3633
sudo apt-get install -y gettext
34+
uv sync
3735
3836
- name: Code checks
39-
run: export SKIP=no-commit-to-branch; pre-commit run --all
37+
run: export SKIP=no-commit-to-branch; uv run pre-commit run --all-files
4038

4139
- name: building addon
42-
run: scons && scons pot
40+
run: uv run scons && uv run scons pot
4341

44-
- uses: actions/upload-artifact@v5
42+
- uses: actions/upload-artifact@v7
4543
with:
4644
name: packaged_addon
4745
path: |
4846
./*.nvda-addon
47+
archive: false
48+
49+
- uses: actions/upload-artifact@v7
50+
with:
51+
name: translation_template
52+
path: |
4953
./*.pot
54+
archive: false
5055

5156
upload_release:
5257
runs-on: ubuntu-latest
@@ -56,8 +61,11 @@ jobs:
5661
contents: write
5762
steps:
5863
- uses: actions/checkout@v6
59-
- name: download releases files
60-
uses: actions/download-artifact@v6
64+
- name: download all artifacts
65+
uses: actions/download-artifact@v8
66+
with:
67+
path: .
68+
merge-multiple: true
6169
- name: Display structure of downloaded files
6270
run: ls -R
6371
- name: Calculate sha256
@@ -66,7 +74,7 @@ jobs:
6674
sha256sum *.nvda-addon >> changelog.md
6775
6876
- name: Release
69-
uses: softprops/action-gh-release@v2
77+
uses: softprops/action-gh-release@v3
7078
with:
7179
files: |
7280
*.nvda-addon

0 commit comments

Comments
 (0)