Skip to content

Commit e11c430

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 73958be commit e11c430

14 files changed

Lines changed: 385 additions & 430 deletions

File tree

.github/scripts/checkTranslation.py

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

6-
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-
7+
def normalize(s: str | None) -> str:
8+
return " ".join((s or "").strip().lower().split())
119

1210
# -----------------------------
13-
# PO FILE CHECK
11+
# PO CHECK
1412
# -----------------------------
15-
def checkPo(path: str) -> float:
16-
# Parse PO file using polib
17-
po = polib.pofile(path)
18-
19-
translated = 0
20-
total = 0
2113

22-
for entry in po:
23-
# Skip empty msgid entries
24-
if not entry.msgid.strip():
25-
continue
14+
def checkPo(path: str) -> float:
15+
po = polib.pofile(path)
16+
translated = 0
17+
total = 0
2618

27-
total += 1
19+
for entry in po:
20+
if not entry.msgid.strip():
21+
continue
2822

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

33-
return translated / total if total else 0.0
25+
if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid):
26+
translated += 1
3427

28+
return translated / total if total else 0.0
3529

3630
# -----------------------------
37-
# XLIFF CHECK (skeleton-safe generic parsing)
31+
# XLIFF CHECK
3832
# -----------------------------
33+
3934
def checkXliff(path: str) -> float:
40-
# Parse XML XLIFF file
41-
tree = ET.parse(path)
42-
root = tree.getroot()
35+
tree = ET.parse(path)
36+
root = tree.getroot()
37+
translated = 0
38+
total = 0
39+
source = None
4340

44-
translated = 0
45-
total = 0
41+
for elem in root.iter():
42+
if elem.tag.endswith("source"):
43+
source = normalize(elem.text)
4644

47-
source = None
45+
elif elem.tag.endswith("target"):
46+
target = normalize(elem.text)
4847

49-
for elem in root.iter():
48+
if source:
49+
total += 1
50+
if target and target != source:
51+
translated += 1
5052

51-
# Capture source segments
52-
if elem.tag.endswith("source"):
53-
source = normalize(elem.text)
53+
return translated / total if total else 0.0
5454

55-
# Compare with target segments
56-
elif elem.tag.endswith("target"):
57-
target = normalize(elem.text)
55+
# -----------------------------
56+
# MD LANGUAGE SCORE (langid)
57+
# -----------------------------
5858

59-
if source:
60-
total += 1
59+
def scoreMd(path: str, expected_lang: str) -> float:
60+
try:
61+
with open(path, "r", encoding="utf-8") as f:
62+
text = f.read()
63+
except Exception:
64+
return 0.0
6165

62-
# Count as translated only if target differs from source
63-
if target and target != source:
64-
translated += 1
66+
if not text.strip():
67+
return 0.0
6568

66-
return translated / total if total else 0.0
69+
lang, score = langid.classify(text)
6770

71+
# Normalize score into positive confidence
72+
confidence = 1 / (1 + abs(score))
73+
74+
if lang == expected_lang:
75+
return confidence
76+
else:
77+
return 0.0
6878

6979
# -----------------------------
70-
# MAIN ENTRY POINT
80+
# COMPARE MULTIPLE MD FILES
7181
# -----------------------------
72-
def main():
73-
if len(sys.argv) < 2:
74-
print("Usage: checkTranslation.py <file>")
75-
sys.exit(2)
7682

77-
path = sys.argv[1]
83+
def compareMd(files: list[str], lang: str):
84+
results = []
7885

79-
if not os.path.exists(path):
80-
print(f"File not found: {path}")
81-
sys.exit(2)
86+
for f in files:
87+
if not os.path.exists(f):
88+
continue
8289

83-
ext = os.path.splitext(path)[1].lower()
90+
score = scoreMd(f, lang)
91+
results.append((f, score))
8492

85-
# Dispatch based on file type
86-
if ext == ".po":
87-
ratio = checkPo(path)
93+
if not results:
94+
print("winner=None")
95+
sys.exit(1)
8896

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

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

96-
print(f"translation_ratio={ratio}")
101+
print("comparison_results:")
102+
for f, s in results:
103+
print(f"{f}={s}")
97104

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
105+
print(f"winner={winner[0]}")
106+
print(f"winner_score={winner[1]}")
103107

108+
sys.exit(0)
109+
110+
# -----------------------------
111+
# MAIN
112+
# -----------------------------
113+
114+
def main():
115+
if len(sys.argv) < 2:
116+
print("Usage:")
117+
print(" checkTranslation.py <file>")
118+
print(" checkTranslation.py <file> <lang>")
119+
print(" checkTranslation.py <file1> <file2> [...] <lang>")
120+
sys.exit(2)
121+
122+
args = sys.argv[1:]
123+
124+
# -------------------------
125+
# MULTI FILE MODE
126+
# -------------------------
127+
if len(args) >= 3:
128+
*files, lang = args
129+
compareMd(files, lang)
130+
return
131+
132+
path = args[0]
133+
134+
if not os.path.exists(path):
135+
print(f"File not found: {path}")
136+
sys.exit(2)
137+
138+
ext = os.path.splitext(path)[1].lower()
139+
140+
# -------------------------
141+
# PO
142+
# -------------------------
143+
if ext == ".po":
144+
ratio = checkPo(path)
145+
print(f"translation_ratio={ratio}")
146+
sys.exit(0 if ratio > 0.05 else 1)
147+
148+
# -------------------------
149+
# XLIFF
150+
# -------------------------
151+
elif ext in [".xliff", ".xlf"]:
152+
ratio = checkXliff(path)
153+
print(f"translation_ratio={ratio}")
154+
sys.exit(0 if ratio > 0.05 else 1)
155+
156+
# -------------------------
157+
# MD (LANG SCORE)
158+
# -------------------------
159+
elif ext == ".md":
160+
if len(args) < 2:
161+
print("Missing language argument for MD scoring")
162+
sys.exit(2)
163+
164+
lang = args[1]
165+
score = scoreMd(path, lang)
166+
167+
print(f"md_score={score}")
168+
sys.exit(0)
169+
170+
else:
171+
print(f"Unsupported file type: {ext}")
172+
sys.exit(2)
104173

105174
if __name__ == "__main__":
106-
main()
175+
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)