Skip to content

Commit 9ffaf33

Browse files
authored
Make the Windows installer translatable (#927)
This adds two Python scripts that extract messages from the Windows installer so we can have them translated in Weblate and reassemble again the installer script with the translations we have available. Both Python scripts were created using Claude Code. Fixes #926
1 parent 8777c6c commit 9ffaf33

File tree

6 files changed

+739
-435
lines changed

6 files changed

+739
-435
lines changed

dist/win/languages.nsh

Lines changed: 391 additions & 392 deletions
Large diffs are not rendered by default.

po/build-translations.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ popd
4343

4444
rm -rf desktop-file
4545

46+
####### NSIS installer translations
47+
./generate-nsis-translations.py ../dist/win/languages.nsh . ../dist/win/languages.nsh
48+

po/extract-nsis-strings.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Extract translatable strings from NSIS language files and output in POT format.
4+
"""
5+
6+
import re
7+
import sys
8+
from datetime import datetime
9+
10+
def extract_nsis_strings(nsis_file):
11+
"""Extract English strings from NSIS language file."""
12+
strings = {}
13+
14+
with open(nsis_file, 'r', encoding='utf-8') as f:
15+
for line in f:
16+
# Match LangString lines with LANG_ENGLISH
17+
match = re.match(r'\s*LangString\s+(\w+)\s+\$\{LANG_ENGLISH\}\s+"(.+)"', line)
18+
if match:
19+
string_id = match.group(1)
20+
string_value = match.group(2)
21+
# Unescape NSIS escaped characters
22+
string_value = string_value.replace('$\\n', '\\n')
23+
string_value = string_value.replace('$\\t', '\\t')
24+
# Keep ${APPNAME} as-is for now, it will be handled by gettext
25+
strings[string_id] = string_value
26+
27+
return strings
28+
29+
def write_pot_file(strings, output_file):
30+
"""Write strings to POT file format."""
31+
with open(output_file, 'w', encoding='utf-8') as f:
32+
# Write POT header
33+
f.write('# NSIS Installer strings for MediaWriter\n')
34+
f.write('# Copyright (C) YEAR THE PACKAGE\'S COPYRIGHT HOLDER\n')
35+
f.write('# This file is distributed under the same license as the PACKAGE package.\n')
36+
f.write('# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.\n')
37+
f.write('#\n')
38+
f.write('#, fuzzy\n')
39+
f.write('msgid ""\n')
40+
f.write('msgstr ""\n')
41+
f.write('"Project-Id-Version: PACKAGE VERSION\\n"\n')
42+
f.write('"Report-Msgid-Bugs-To: \\n"\n')
43+
f.write(f'"POT-Creation-Date: {datetime.now().strftime("%Y-%m-%d %H:%M%z")}\\n"\n')
44+
f.write('"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"\n')
45+
f.write('"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"\n')
46+
f.write('"Language-Team: LANGUAGE <LL@li.org>\\n"\n')
47+
f.write('"Language: \\n"\n')
48+
f.write('"MIME-Version: 1.0\\n"\n')
49+
f.write('"Content-Type: text/plain; charset=UTF-8\\n"\n')
50+
f.write('"Content-Transfer-Encoding: 8bit\\n"\n')
51+
f.write('\n')
52+
53+
# Write each string with context
54+
for string_id, string_value in sorted(strings.items()):
55+
f.write(f'#: NSIS:{string_id}\n')
56+
f.write(f'msgctxt "NSIS:{string_id}"\n')
57+
# Escape quotes in the string, but avoid double-escaping already escaped quotes
58+
escaped_value = re.sub(r'(?<!\\\\)"', r'\\"', string_value)
59+
f.write(f'msgid "{escaped_value}"\n')
60+
f.write('msgstr ""\n')
61+
f.write('\n')
62+
63+
if __name__ == '__main__':
64+
if len(sys.argv) != 3:
65+
print(f'Usage: {sys.argv[0]} <nsis-language-file> <output-pot-file>')
66+
sys.exit(1)
67+
68+
nsis_file = sys.argv[1]
69+
output_file = sys.argv[2]
70+
71+
strings = extract_nsis_strings(nsis_file)
72+
write_pot_file(strings, output_file)
73+
74+
print(f'Extracted {len(strings)} strings from {nsis_file} to {output_file}')

po/generate-nsis-translations.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate NSIS language file from PO translations.
4+
"""
5+
6+
import glob
7+
import os
8+
import re
9+
import sys
10+
import polib
11+
from collections import defaultdict
12+
13+
# Mapping from gettext language codes to NSIS language codes
14+
LANGUAGE_MAP = {
15+
'af': 'AFRIKAANS',
16+
'sq': 'ALBANIAN',
17+
'ar': 'ARABIC',
18+
'be': 'BELARUSIAN',
19+
'bs': 'BOSNIAN',
20+
'br': 'BRETON',
21+
'bg': 'BULGARIAN',
22+
'ca': 'CATALAN',
23+
'hr': 'CROATIAN',
24+
'cs': 'CZECH',
25+
'da': 'DANISH',
26+
'nl': 'DUTCH',
27+
'eo': 'ESPERANTO',
28+
'et': 'ESTONIAN',
29+
'fa': 'FARSI',
30+
'fi': 'FINNISH',
31+
'fr': 'FRENCH',
32+
'gl': 'GALICIAN',
33+
'de': 'GERMAN',
34+
'el': 'GREEK',
35+
'he': 'HEBREW',
36+
'hu': 'HUNGARIAN',
37+
'is': 'ICELANDIC',
38+
'id': 'INDONESIAN',
39+
'ga': 'IRISH',
40+
'it': 'ITALIAN',
41+
'ja': 'JAPANESE',
42+
'ko': 'KOREAN',
43+
'ku': 'KURDISH',
44+
'lv': 'LATVIAN',
45+
'lt': 'LITHUANIAN',
46+
'lb': 'LUXEMBOURGISH',
47+
'mk': 'MACEDONIAN',
48+
'ms': 'MALAY',
49+
'mn': 'MONGOLIAN',
50+
'nb': 'NORWEGIAN',
51+
'nn': 'NORWEGIANNYNORSK',
52+
'pl': 'POLISH',
53+
'pt': 'PORTUGUESE',
54+
'pt-BR': 'PORTUGUESEBR',
55+
'pt_BR': 'PORTUGUESEBR',
56+
'ro': 'ROMANIAN',
57+
'ru': 'RUSSIAN',
58+
'sr': 'SERBIAN',
59+
'sr-Latn': 'SERBIANLATIN',
60+
'zh-CN': 'SIMPCHINESE',
61+
'zh_CN': 'SIMPCHINESE',
62+
'sk': 'SLOVAK',
63+
'sl': 'SLOVENIAN',
64+
'es': 'SPANISH',
65+
'es-ES': 'SPANISHINTERNATIONAL',
66+
'sv': 'SWEDISH',
67+
'th': 'THAI',
68+
'zh-TW': 'TRADCHINESE',
69+
'zh_TW': 'TRADCHINESE',
70+
'tr': 'TURKISH',
71+
'uk': 'UKRAINIAN',
72+
'uz': 'UZBEK',
73+
}
74+
75+
# All NSIS languages (for complete coverage)
76+
ALL_NSIS_LANGUAGES = [
77+
'AFRIKAANS', 'ALBANIAN', 'ARABIC', 'BELARUSIAN', 'BOSNIAN', 'BRETON',
78+
'BULGARIAN', 'CATALAN', 'CROATIAN', 'CZECH', 'DANISH', 'DUTCH',
79+
'ESPERANTO', 'ESTONIAN', 'FARSI', 'FINNISH', 'FRENCH', 'GALICIAN',
80+
'GERMAN', 'GREEK', 'HEBREW', 'HUNGARIAN', 'ICELANDIC', 'INDONESIAN',
81+
'IRISH', 'ITALIAN', 'JAPANESE', 'KOREAN', 'KURDISH', 'LATVIAN',
82+
'LITHUANIAN', 'LUXEMBOURGISH', 'MACEDONIAN', 'MALAY', 'MONGOLIAN',
83+
'NORWEGIAN', 'NORWEGIANNYNORSK', 'POLISH', 'PORTUGUESE', 'PORTUGUESEBR',
84+
'ROMANIAN', 'RUSSIAN', 'SERBIAN', 'SERBIANLATIN', 'SIMPCHINESE',
85+
'SLOVAK', 'SLOVENIAN', 'SPANISH', 'SPANISHINTERNATIONAL', 'SWEDISH',
86+
'THAI', 'TRADCHINESE', 'TURKISH', 'UKRAINIAN', 'UZBEK',
87+
]
88+
89+
def read_po_files(po_dir, english_strings):
90+
"""Read all PO files and extract NSIS translations."""
91+
92+
# Initialize with English strings
93+
translations = defaultdict(lambda: defaultdict(str))
94+
for string_id, english_text in english_strings.items():
95+
translations[string_id]['ENGLISH'] = english_text
96+
97+
# Read all PO files
98+
po_files = glob.glob(os.path.join(po_dir, 'mediawriter_*.po'))
99+
for po_file in po_files:
100+
# Extract language code from filename
101+
basename = os.path.basename(po_file)
102+
lang_code = basename.replace('mediawriter_', '').replace('.po', '')
103+
104+
# Map to NSIS language code
105+
nsis_lang = LANGUAGE_MAP.get(lang_code)
106+
if not nsis_lang:
107+
print(f'Warning: No NSIS mapping for language {lang_code}, skipping {po_file}', file=sys.stderr)
108+
continue
109+
110+
# Parse PO file
111+
try:
112+
po = polib.pofile(po_file)
113+
for entry in po:
114+
# Check if this is an NSIS string
115+
if entry.msgctxt and entry.msgctxt.startswith('NSIS:'):
116+
string_id = entry.msgctxt.replace('NSIS:', '')
117+
if entry.msgstr:
118+
translations[string_id][nsis_lang] = entry.msgstr
119+
except Exception as e:
120+
print(f'Error reading {po_file}: {e}', file=sys.stderr)
121+
122+
return translations
123+
124+
def write_nsis_file(translations, output_file):
125+
"""Write translations to NSIS language file."""
126+
# Get list of all string IDs
127+
string_ids = sorted(translations.keys())
128+
129+
# Create list with ENGLISH first, then all others
130+
all_languages = ['ENGLISH'] + ALL_NSIS_LANGUAGES
131+
132+
with open(output_file, 'w', encoding='utf-8') as f:
133+
for i, string_id in enumerate(string_ids):
134+
if i > 0:
135+
f.write('\n')
136+
137+
# Get English string as fallback
138+
english_text = translations[string_id].get('ENGLISH', '')
139+
140+
# Write LangString for each language (ENGLISH first, then others)
141+
for nsis_lang in all_languages:
142+
# Get translation or fall back to English
143+
text = translations[string_id].get(nsis_lang, english_text)
144+
145+
# Escape for NSIS
146+
text = text.replace('\\n', '$\\n')
147+
text = text.replace('\\t', '$\\t')
148+
# Escape unescaped double quotes, but leave existing NSIS escapes ($\\") intact
149+
text = re.sub(r'(?<!\$\\)"', r'$\\\"', text)
150+
151+
# Write LangString with fixed 2 spaces between language and quote
152+
f.write(f'LangString {string_id} ${{LANG_{nsis_lang}}} "{text}"\n')
153+
154+
def extract_english_strings(nsis_file):
155+
"""Extract English strings from existing NSIS file."""
156+
strings = {}
157+
158+
with open(nsis_file, 'r', encoding='utf-8') as f:
159+
for line in f:
160+
match = re.match(r'\s*LangString\s+(\w+)\s+\$\{LANG_ENGLISH\}\s+"(.+)"', line)
161+
if match:
162+
string_id = match.group(1)
163+
string_value = match.group(2)
164+
# Unescape NSIS escaped characters
165+
string_value = string_value.replace('$\\n', '\\n')
166+
string_value = string_value.replace('$\\t', '\\t')
167+
string_value = string_value.replace('$\\"', '"')
168+
strings[string_id] = string_value
169+
170+
return strings
171+
172+
if __name__ == '__main__':
173+
if len(sys.argv) != 4:
174+
print(f'Usage: {sys.argv[0]} <original-nsis-file> <po-directory> <output-nsis-file>')
175+
sys.exit(1)
176+
177+
original_nsis = sys.argv[1]
178+
po_dir = sys.argv[2]
179+
output_file = sys.argv[3]
180+
181+
# Extract English strings from original file
182+
english_strings = extract_english_strings(original_nsis)
183+
print(f'Extracted {len(english_strings)} English strings from {original_nsis}')
184+
185+
# Read translations from PO files
186+
translations = read_po_files(po_dir, english_strings)
187+
188+
# Write output file
189+
write_nsis_file(translations, output_file)
190+
191+
print(f'Generated {output_file} with translations')

po/generate-pot-files.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ lupdate-qt6 ../src/app/qml.qrc ../src/app/*.cpp ../src/app/*.h ../src/helper/lin
55
lconvert-qt6 -of po -o app.pot mediawriter.ts
66
xgettext ../src/app/data/org.fedoraproject.MediaWriter.desktop -o desktop.pot
77
itstool -i as-metainfo.its -o appstream.pot ../src/app/data/org.fedoraproject.MediaWriter.metainfo.xml.in
8+
./extract-nsis-strings.py ../dist/win/languages.nsh nsis.pot
89
msgcat *.pot > mediawriter.pot
9-
rm app.pot appstream.pot desktop.pot
10+
rm app.pot appstream.pot desktop.pot nsis.pot

0 commit comments

Comments
 (0)