Skip to content

Commit ff8722f

Browse files
committed
Make the Windows installer translatable
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 ff8722f

File tree

6 files changed

+738
-435
lines changed

6 files changed

+738
-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
58+
escaped_value = string_value.replace('"', '\\"')
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: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate NSIS language file from PO translations.
4+
"""
5+
6+
import re
7+
import sys
8+
import polib
9+
from collections import defaultdict
10+
11+
# Mapping from gettext language codes to NSIS language codes
12+
LANGUAGE_MAP = {
13+
'af': 'AFRIKAANS',
14+
'sq': 'ALBANIAN',
15+
'ar': 'ARABIC',
16+
'be': 'BELARUSIAN',
17+
'bs': 'BOSNIAN',
18+
'br': 'BRETON',
19+
'bg': 'BULGARIAN',
20+
'ca': 'CATALAN',
21+
'hr': 'CROATIAN',
22+
'cs': 'CZECH',
23+
'da': 'DANISH',
24+
'nl': 'DUTCH',
25+
'eo': 'ESPERANTO',
26+
'et': 'ESTONIAN',
27+
'fa': 'FARSI',
28+
'fi': 'FINNISH',
29+
'fr': 'FRENCH',
30+
'gl': 'GALICIAN',
31+
'de': 'GERMAN',
32+
'el': 'GREEK',
33+
'he': 'HEBREW',
34+
'hu': 'HUNGARIAN',
35+
'is': 'ICELANDIC',
36+
'id': 'INDONESIAN',
37+
'ga': 'IRISH',
38+
'it': 'ITALIAN',
39+
'ja': 'JAPANESE',
40+
'ko': 'KOREAN',
41+
'ku': 'KURDISH',
42+
'lv': 'LATVIAN',
43+
'lt': 'LITHUANIAN',
44+
'lb': 'LUXEMBOURGISH',
45+
'mk': 'MACEDONIAN',
46+
'ms': 'MALAY',
47+
'mn': 'MONGOLIAN',
48+
'nb': 'NORWEGIAN',
49+
'nn': 'NORWEGIANNYNORSK',
50+
'pl': 'POLISH',
51+
'pt': 'PORTUGUESE',
52+
'pt-BR': 'PORTUGUESEBR',
53+
'pt_BR': 'PORTUGUESEBR',
54+
'ro': 'ROMANIAN',
55+
'ru': 'RUSSIAN',
56+
'sr': 'SERBIAN',
57+
'sr-Latn': 'SERBIANLATIN',
58+
'zh-CN': 'SIMPCHINESE',
59+
'zh_CN': 'SIMPCHINESE',
60+
'sk': 'SLOVAK',
61+
'sl': 'SLOVENIAN',
62+
'es': 'SPANISH',
63+
'es-ES': 'SPANISHINTERNATIONAL',
64+
'sv': 'SWEDISH',
65+
'th': 'THAI',
66+
'zh-TW': 'TRADCHINESE',
67+
'zh_TW': 'TRADCHINESE',
68+
'tr': 'TURKISH',
69+
'uk': 'UKRAINIAN',
70+
'uz': 'UZBEK',
71+
}
72+
73+
# All NSIS languages (for complete coverage)
74+
ALL_NSIS_LANGUAGES = [
75+
'AFRIKAANS', 'ALBANIAN', 'ARABIC', 'BELARUSIAN', 'BOSNIAN', 'BRETON',
76+
'BULGARIAN', 'CATALAN', 'CROATIAN', 'CZECH', 'DANISH', 'DUTCH',
77+
'ESPERANTO', 'ESTONIAN', 'FARSI', 'FINNISH', 'FRENCH', 'GALICIAN',
78+
'GERMAN', 'GREEK', 'HEBREW', 'HUNGARIAN', 'ICELANDIC', 'INDONESIAN',
79+
'IRISH', 'ITALIAN', 'JAPANESE', 'KOREAN', 'KURDISH', 'LATVIAN',
80+
'LITHUANIAN', 'LUXEMBOURGISH', 'MACEDONIAN', 'MALAY', 'MONGOLIAN',
81+
'NORWEGIAN', 'NORWEGIANNYNORSK', 'POLISH', 'PORTUGUESE', 'PORTUGUESEBR',
82+
'ROMANIAN', 'RUSSIAN', 'SERBIAN', 'SERBIANLATIN', 'SIMPCHINESE',
83+
'SLOVAK', 'SLOVENIAN', 'SPANISH', 'SPANISHINTERNATIONAL', 'SWEDISH',
84+
'THAI', 'TRADCHINESE', 'TURKISH', 'UKRAINIAN', 'UZBEK',
85+
]
86+
87+
def read_po_files(po_dir, english_strings):
88+
"""Read all PO files and extract NSIS translations."""
89+
import os
90+
import glob
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+
text = text.replace('"', '$\\"')
149+
150+
# Write LangString with fixed 2 spaces between language and quote
151+
f.write(f'LangString {string_id} ${{LANG_{nsis_lang}}} "{text}"\n')
152+
153+
def extract_english_strings(nsis_file):
154+
"""Extract English strings from existing NSIS file."""
155+
strings = {}
156+
157+
with open(nsis_file, 'r', encoding='utf-8') as f:
158+
for line in f:
159+
match = re.match(r'\s*LangString\s+(\w+)\s+\$\{LANG_ENGLISH\}\s+"(.+)"', line)
160+
if match:
161+
string_id = match.group(1)
162+
string_value = match.group(2)
163+
# Unescape NSIS escaped characters
164+
string_value = string_value.replace('$\\n', '\\n')
165+
string_value = string_value.replace('$\\t', '\\t')
166+
string_value = string_value.replace('$\\"', '"')
167+
strings[string_id] = string_value
168+
169+
return strings
170+
171+
if __name__ == '__main__':
172+
if len(sys.argv) != 4:
173+
print(f'Usage: {sys.argv[0]} <original-nsis-file> <po-directory> <output-nsis-file>')
174+
sys.exit(1)
175+
176+
original_nsis = sys.argv[1]
177+
po_dir = sys.argv[2]
178+
output_file = sys.argv[3]
179+
180+
# Extract English strings from original file
181+
english_strings = extract_english_strings(original_nsis)
182+
print(f'Extracted {len(english_strings)} English strings from {original_nsis}')
183+
184+
# Read translations from PO files
185+
translations = read_po_files(po_dir, english_strings)
186+
187+
# Write output file
188+
write_nsis_file(translations, output_file)
189+
190+
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)