Ctrl K
ComunidadGuías…

windows/nsis_translations.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.

"""Generate NSIS translation resources from PO files.

Reads strings:
- English strings from windows/NsisInclude/NsisMultiUserLang.nsh
- localized from strings po/*.po

Then, it writes:
- English strings to po/nsis.pot (template)
- localized strings to windows/NsisInclude/NsisMultiUserLang.nsh

This script is invoked by ``make nsis.pot`` in po/ (``po/Makefile``)
or run manually via CLI flags."""

import argparse
import ast
import io
import os
import re
import sys
import tempfile
import unittest
import unittest.mock

ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PO_DIR = os.path.join(ROOT_DIR, 'po')
NSIS_MULTIUSER_LANG = os.path.join(
    ROOT_DIR, 'windows', 'NsisInclude', 'NsisMultiUserLang.nsh')
OUTPUT_NSH = NSIS_MULTIUSER_LANG
OUTPUT_POT = os.path.join(PO_DIR, 'nsis.pot')

LANGUAGE_MAP = [
    ('en', 'LANG_ENGLISH'),
    ('sq', 'LANG_ALBANIAN'),
    ('ar', 'LANG_ARABIC'),
    ('ast', 'LANG_ASTURIAN'),
    ('eu', 'LANG_BASQUE'),
    ('be', 'LANG_BELARUSIAN'),
    ('bs', 'LANG_BOSNIAN'),
    ('bg', 'LANG_BULGARIAN'),
    ('ca', 'LANG_CATALAN'),
    ('hr', 'LANG_CROATIAN'),
    ('cs', 'LANG_CZECH'),
    ('da', 'LANG_DANISH'),
    ('nl', 'LANG_DUTCH'),
    ('et', 'LANG_ESTONIAN'),
    ('fi', 'LANG_FINNISH'),
    ('fr', 'LANG_FRENCH'),
    ('gl', 'LANG_GALICIAN'),
    ('de', 'LANG_GERMAN'),
    ('el', 'LANG_GREEK'),
    ('he', 'LANG_HEBREW'),
    ('hu', 'LANG_HUNGARIAN'),
    ('id', 'LANG_INDONESIAN'),
    ('it', 'LANG_ITALIAN'),
    ('ja', 'LANG_JAPANESE'),
    ('ko', 'LANG_KOREAN'),
    ('lv', 'LANG_LATVIAN'),
    ('lt', 'LANG_LITHUANIAN'),
    ('ms', 'LANG_MALAY'),
    ('nb', 'LANG_NORWEGIAN'),
    ('pl', 'LANG_POLISH'),
    ('pt', 'LANG_PORTUGUESE'),
    ('pt_BR', 'LANG_PORTUGUESEBR'),
    ('ro', 'LANG_ROMANIAN'),
    ('ru', 'LANG_RUSSIAN'),
    ('sr', 'LANG_SERBIAN'),
    ('zh_CN', 'LANG_SIMPCHINESE'),
    ('sk', 'LANG_SLOVAK'),
    ('sl', 'LANG_SLOVENIAN'),
    ('es', 'LANG_SPANISH'),
    ('sv', 'LANG_SWEDISH'),
    ('th', 'LANG_THAI'),
    ('zh_TW', 'LANG_TRADCHINESE'),
    ('tr', 'LANG_TURKISH'),
    ('uk', 'LANG_UKRAINIAN'),
    ('uz', 'LANG_UZBEK'),
    ('vi', 'LANG_VIETNAMESE'),
]

TRANSLATION_HINTS = {
    'ALREADY_INSTALLED': 'Dialog shown when an older version is detected. {prodname} is replaced with the product name. Do not translate placeholder.',
    'BLEACHBIT_RUNNING': 'Dialog shown when upgrading while the old version of BleachBit is still running. Instructs the user to close it and click Retry, or click Cancel to exit.',
    'OLD_WINDOWS_WARNING': 'Dialog shown when the installer detects an old version of Windows. Instructs the user to click OK to download a compatible version or Cancel to exit.',
    'MULTIUSER_ADMIN_CREDENTIALS_REQUIRED': 'Message shown on the installation mode page description when admin rights are needed.',
    'MULTIUSER_ADMIN_UNINSTALL_CREDENTIALS_REQUIRED': 'Message shown on the installation mode page description when admin rights are needed to uninstall.',
    'MULTIUSER_ALL_USERS_UMUI': 'Radio button label for Ultra Modern UI (NSIS). Same as MULTIUSER_ALL_USERS but without the & accelerator.',
    'MULTIUSER_ALL_USERS': 'Radio button label. The & sets the keyboard accelerator (hotkey). Place & before the letter that should be the shortcut key.',
    'MULTIUSER_CURRENT_USER_UMUI': 'Radio button label for Ultra Modern UI (NSIS). Same as MULTIUSER_CURRENT_USER but without the & accelerator. {USER} is a placeholder that will be replaced with the Windows username.',
    'MULTIUSER_CURRENT_USER': 'Radio button label. The & sets the keyboard accelerator (hotkey). Place & before the letter that should be the shortcut key. {USER} is a placeholder that will be replaced with the Windows username.',
    'MULTIUSER_ELEVATION_ERROR': 'Error message shown in dialog box during installation on Windows. {ERROR} is a placeholder that will be replaced with the error details.',
    'MULTIUSER_ELEVATION_NOT_SUPPORTED': 'Error message shown in a dialog box when Windows does not support elevation.',
    'MULTIUSER_INSTALLED_ALL_USERS': 'Radio button option for the method of installation. {VERSION} and {FOLDER} are placeholders replaced by the installer with the installed version and folder path.',
    'MULTIUSER_INSTALLED_CURRENT_USER': 'Radio button option for the method of installation. {VERSION} and {FOLDER} are placeholders replaced by the installer with the installed version and folder path.',
    'MULTIUSER_PAGE_TITLE': 'Title of the installation mode page in the Windows installer. User selects whether to install for all users or the current user.',
    'SECTION_DESKTOP_NAME': 'Name of the desktop section in the Windows installer: user selects which desktop items to install.',
    'SECTION_QUICK_LAUNCH_NAME': 'Name of the quick launch section in the Windows installer: quick launch is a toolbar in the Windows taskbar that allows users to quickly access frequently used programs.',
    'SECTION_SHORTCUTS_NAME': 'Name of the shortcuts section in the Windows installer: user selects which shortcuts to install.',
    'SECTION_START_MENU_NAME': 'Name of the start menu section in the Windows installer: user selects which start menu items to install.',
    'SECTION_TRANSLATIONS_NAME': 'Name of the translations section in the Windows installer: user selects which translations to install.',
    'SHORTCUT_DEBUGGING_TERMINAL': 'Shortcut name for a desktop shortcut that opens a debugging terminal.',
    'SHORTCUT_NO_UAC': 'Shortcut name for a desktop shortcut that runs without UAC elevation (admin privileges).',
    'SHRED_SHELL_MENU': 'Context menu item in Windows Explorer for securely shredding files.',
}


def _get_all_nsis_langstring_keys():
    """Extract all unique LangString key names from the NSIS language file."""
    _header, lang_strings = _parse_nsis_lang_strings(NSIS_MULTIUSER_LANG)
    all_keys = set()
    for _lang_macro, strings in lang_strings.items():
        all_keys.update(strings.keys())
    return all_keys


def validate_translation_hints():
    """Validate that all TRANSLATION_HINTS keys correspond to real LangString definitions.

    Raises:
        RuntimeError: If any hints reference non-existent LangString keys.
    """
    actual_keys = _get_all_nsis_langstring_keys()
    hint_keys = set(TRANSLATION_HINTS.keys())
    orphaned_hints = hint_keys - actual_keys

    if orphaned_hints:
        raise RuntimeError(
            f'TRANSLATION_HINTS contains orphaned keys not found in NSIS files: '
            f'{sorted(orphaned_hints)}'
        )


def validate_keys_usage():
    """Validate that all LangString keys are used in NSIS installer files.

    Checks that each key in windows/NsisInclude/NsisMultiUserLang.nsh is
    referenced in either windows/bleachbit.nsi or
    windows/NsisInclude/NsisMultiUser.nsh.

    Raises:
        RuntimeError: If any keys are not found in the NSIS files.
    """
    nsis_files = [
        os.path.join(ROOT_DIR, 'windows', 'bleachbit.nsi'),
        os.path.join(ROOT_DIR, 'windows', 'NsisInclude', 'NsisMultiUser.nsh')
    ]

    # Collect all text from NSIS files
    all_nsis_content = ''
    for nsis_file in nsis_files:
        if os.path.exists(nsis_file):
            with open(nsis_file, encoding='utf-8') as f:
                all_nsis_content += f.read()

    # Check each key from the NSIS language file (includes all keys from po/nsis.pot)
    unused_keys = []
    for key in _get_all_nsis_langstring_keys():
        # NSIS references LangStrings as $(KEY)
        pattern = rf'\$\({re.escape(key)}\)'
        if not re.search(pattern, all_nsis_content):
            unused_keys.append(key)

    if unused_keys:
        raise RuntimeError(
            f'LangString keys not used in NSIS files: '
            f'{sorted(unused_keys)}'
        )


def _parse_po_string(token):
    """Return the decoded PO string literal from the raw token."""
    token = token.strip()
    if not token:
        return ''
    return ast.literal_eval(token)


def _parse_po_file(po_path, expected_msgids=None):
    """Parse a PO file and return a mapping of msgctxt to translated text.

    Untranslated and fuzzy entries map to the msgid, as with msgfmt.
    If expected_msgids is provided, entries whose msgid does not match
    expected_msgids[msgctxt] are skipped so stale translations are not used.
    """
    entries = {}
    current_ctx = None
    current_id = None
    current_str = None
    fuzzy = False
    state = None

    with open(po_path, encoding='utf-8') as po_file:
        # The extra blank line ends the last entry
        for raw_line in [*po_file, '\n']:
            line = raw_line.rstrip('\n')
            if not line.strip():
                # A blank line ends the entry, so a following entry without
                # msgctxt cannot overwrite it
                if current_ctx and current_id is not None and current_str is not None:
                    if expected_msgids is None or expected_msgids.get(current_ctx) == current_id:
                        use_str = current_str and not fuzzy
                        entries[current_ctx] = current_str if use_str else current_id
                current_ctx = current_id = current_str = state = None
                fuzzy = False
                continue
            if line.startswith('#,') and 'fuzzy' in line:
                fuzzy = True
                continue
            if line.startswith('#'):
                continue
            if line.startswith('msgctxt '):
                current_ctx = _parse_po_string(line[len('msgctxt '):])
                current_id = None
                current_str = None
                state = 'msgctxt'
                continue
            if line.startswith('msgid '):
                current_id = _parse_po_string(line[len('msgid '):])
                state = 'msgid'
                continue
            if line.startswith('msgstr '):
                current_str = _parse_po_string(line[len('msgstr '):])
                state = 'msgstr'
                continue
            if line.startswith('"'):
                text = _parse_po_string(line)
                if state == 'msgctxt' and current_ctx is not None:
                    current_ctx += text
                elif state == 'msgid' and current_id is not None:
                    current_id += text
                elif state == 'msgstr' and current_str is not None:
                    current_str += text

    return entries


_PLACEHOLDER_RE = re.compile(r'\$\{[A-Za-z_][A-Za-z0-9_]*\}')


def _checked_translation(locale, key, text, english):
    """Return text, or english when its ${...} placeholders differ.

    A translator can drop or mangle a placeholder (Hungarian once shipped
    "$" for "${prodname}"), and tests/TestTranslations.py only reports it
    after the .po is on master. Fall back the way msgfmt does for fuzzy.
    """
    expected = sorted(_PLACEHOLDER_RE.findall(english))
    actual = sorted(_PLACEHOLDER_RE.findall(text))
    if expected == actual:
        return text
    print(f'WARNING: {locale} {key}: placeholders {actual} differ from '
          f'English {expected}, using the English text', file=sys.stderr)
    return english


def _parse_nsis_lang_strings(nsh_path):
    """Extract header lines and LangString definitions from an NSIS file."""
    header_lines = []
    lang_strings = {}
    current_lang = None
    in_header = True

    pattern = re.compile(
        r'^\s*LangString\s+(\S+)\s+\$\{(LANG_[^}]+)\}\s+"(.*)"\s*$')

    with open(nsh_path, encoding='utf-8') as nsh_file:
        for line in nsh_file:
            stripped = line.strip()
            if stripped.startswith('!ifdef LANG_'):
                current_lang = stripped.split()[1]
                lang_strings.setdefault(current_lang, {})
                in_header = False
                continue
            if current_lang and stripped.startswith('!endif'):
                current_lang = None
                continue
            if current_lang is None and in_header:
                if stripped.startswith('; This file is auto-generated'):
                    in_header = False
                    continue
                header_lines.append(line)
                continue

            match = pattern.match(line)
            if match:
                key, lang_macro, text = match.groups()
                lang_strings.setdefault(lang_macro, {})[
                    key] = _unescape_nsis_text(text)

    return header_lines, lang_strings


def _unescape_nsis_text(text):
    """Convert NSIS-escaped sequences to plain text for PO generation."""
    text = text.replace(r'$\r$\n', '\n')
    text = text.replace(r'$\n', '\n').replace(r'$\r', '\n')
    text = text.replace(r'$\"', '"')
    return text


def _escape_po_text(text):
    """Escape text for safe inclusion inside PO msgid/msgstr entries."""
    return (text.replace('\\', '\\\\')
                .replace('"', '\\"')
                .replace('\n', '\\n'))


def _escape_nsis_text(text):
    """Escape arbitrary text so it is valid inside an NSIS LangString."""
    text = text.replace('\r\n', '\n').replace('\r', '\n')
    text = text.replace('\n', r'$\r$\n')
    text = text.replace('"', r'$\"')
    return text


def write_nsis_pot():
    """Generate the template POT file using the English NSIS strings."""
    # Validate hints before generating POT
    validate_translation_hints()
    _header, lang_strings = _parse_nsis_lang_strings(NSIS_MULTIUSER_LANG)
    english_strings = lang_strings.get('LANG_ENGLISH', {})
    if not english_strings:
        raise RuntimeError(
            'No LANG_ENGLISH strings found in NSIS language file.')
    header = (
        'msgid ""\n'
        'msgstr ""\n'
        '"Project-Id-Version: bleachbit\\n"\n'
        '"Content-Type: text/plain; charset=UTF-8\\n"\n'
        '"Content-Transfer-Encoding: 8bit\\n"\n\n'
    )

    lines = [header]
    for key in sorted(english_strings):
        msgid = _escape_po_text(_unescape_nsis_text(english_strings[key]))
        lines.append('#: windows/NsisInclude/NsisMultiUserLang.nsh\n')
        hint = TRANSLATION_HINTS.get(key)
        if hint:
            lines.append(f'#. TRANSLATORS: {hint}\n')
        if '&' in msgid:
            lines.append(
                '#. TRANSLATORS: The character \'&\' positions the keyboard accelerator key.\n'
                '#. TRANSLATORS: For example, \'&anyone\' sets \'a\' as the accelerator key (Alt+a).\n')
        lines.append(f'msgctxt "nsis:{key}"\n')
        lines.append(f'msgid "{msgid}"\n')
        lines.append('msgstr ""\n\n')

    with open(OUTPUT_POT, 'w', encoding='utf-8', newline='\n') as pot_file:
        pot_file.write(''.join(lines))


def write_nsis_langfile():
    """Render the NSIS LangString include file from PO translation data."""
    header_lines, lang_strings = _parse_nsis_lang_strings(NSIS_MULTIUSER_LANG)
    english_strings = lang_strings.get('LANG_ENGLISH', {})
    if not english_strings:
        raise RuntimeError(
            'No LANG_ENGLISH strings found in NSIS language file.')
    translations = {}
    expected_msgids = {f'nsis:{k}': v for k, v in english_strings.items()}

    for locale, _lang_macro in LANGUAGE_MAP:
        if locale == 'en':
            translations[locale] = {}
            continue
        po_path = os.path.join(PO_DIR, f'{locale}.po')
        if not os.path.exists(po_path):
            translations[locale] = {}
            continue
        entries = _parse_po_file(po_path, expected_msgids)
        nsis_entries = {}
        for key, value in entries.items():
            if key.startswith('nsis:'):
                key = key[len('nsis:'):]
                nsis_entries[key] = _checked_translation(
                    locale, key, value, english_strings[key])
        translations[locale] = nsis_entries

    lines = list(header_lines)
    if lines and not lines[-1].endswith('\n'):
        lines[-1] = lines[-1] + '\n'
    lines.extend([
        '; This file is auto-generated by windows/nsis_translations.py\n',
        '; Do not edit manually.\n',
        '; Source: po/*.po\n',
        '\n',
    ])

    for locale, lang_macro in LANGUAGE_MAP:
        lines.append(f'!ifdef {lang_macro}\n')
        locale_entries = translations.get(locale, {})
        for key in sorted(english_strings):
            if locale == 'en':
                text = english_strings[key]
            else:
                text = locale_entries.get(key) or english_strings[key]
            lines.append(
                f'\tLangString {key} ${{{lang_macro}}} "{_escape_nsis_text(text)}"\n')
        lines.append('!endif\n\n')

    with open(OUTPUT_NSH, 'w', encoding='utf-8', newline='\n') as nsh_file:
        nsh_file.write(''.join(lines))


class TestNsisEscaping(unittest.TestCase):
    """Test cases for NSIS text escaping and unescaping functions."""

    def test_unescape_nsis_newlines(self):
        """Test unescaping NSIS newline sequences."""
        self.assertEqual(_unescape_nsis_text(
            r'Line1$\r$\nLine2'), 'Line1\nLine2')
        self.assertEqual(_unescape_nsis_text(r'Line1$\nLine2'), 'Line1\nLine2')
        self.assertEqual(_unescape_nsis_text(r'Line1$\rLine2'), 'Line1\nLine2')

    def test_unescape_nsis_quotes(self):
        """Test unescaping NSIS quote sequences."""
        self.assertEqual(_unescape_nsis_text(
            r'Text with $\"quotes$\"'), 'Text with "quotes"')

    def test_unescape_nsis_dollar_signs(self):
        """Test that literal dollar signs in NSIS strings are preserved."""
        self.assertEqual(_unescape_nsis_text(r'$\"{FOLDER}$\"'), '"{FOLDER}"')
        self.assertEqual(_unescape_nsis_text('Price: $100'), 'Price: $100')

    def test_unescape_nsis_no_double_escaping(self):
        """Regression test: ensure we don't treat $$ as an escape sequence."""
        text = r'$\"{FOLDER}$\"'
        result = _unescape_nsis_text(text)
        self.assertEqual(result, '"{FOLDER}"')
        self.assertNotIn('$$', result)

    def test_escape_po_text_backslashes(self):
        """Test escaping backslashes for PO format."""
        self.assertEqual(_escape_po_text(
            'path\\to\\file'), 'path\\\\to\\\\file')

    def test_escape_po_text_quotes(self):
        """Test escaping quotes for PO format."""
        self.assertEqual(_escape_po_text('Say "hello"'), 'Say \\"hello\\"')

    def test_escape_po_text_newlines(self):
        """Test escaping newlines for PO format."""
        self.assertEqual(_escape_po_text('Line1\nLine2'), 'Line1\\nLine2')

    def test_escape_nsis_newlines(self):
        """Test escaping newlines for NSIS format."""
        self.assertEqual(_escape_nsis_text(
            'Line1\nLine2'), 'Line1$\\r$\\nLine2')
        self.assertEqual(_escape_nsis_text(
            'Line1\r\nLine2'), 'Line1$\\r$\\nLine2')

    def test_escape_nsis_quotes(self):
        """Test escaping quotes for NSIS format."""
        self.assertEqual(_escape_nsis_text(
            'Text with "quotes"'), 'Text with $\\"quotes$\\"')

    def test_escape_nsis_no_dollar_escaping(self):
        """Regression test: ensure we don't escape literal $ as $$."""
        text = 'Price: $100'
        result = _escape_nsis_text(text)
        self.assertEqual(result, 'Price: $100')
        self.assertNotIn('$$', result)

    def test_roundtrip_nsis_to_po_to_nsis(self):
        """Test that NSIS -> PO -> NSIS roundtrip preserves content."""
        original_nsis = r'Version {VERSION} is installed in $\"{FOLDER}$\".'
        unescaped = _unescape_nsis_text(original_nsis)
        self.assertEqual(
            unescaped, 'Version {VERSION} is installed in "{FOLDER}".')
        po_escaped = _escape_po_text(unescaped)
        self.assertEqual(
            po_escaped, 'Version {VERSION} is installed in \\"{FOLDER}\\".')
        back_to_plain = po_escaped.replace('\\n', '\n').replace(
            '\\"', '"').replace('\\\\', '\\')
        self.assertEqual(
            back_to_plain, 'Version {VERSION} is installed in "{FOLDER}".')
        back_to_nsis = _escape_nsis_text(back_to_plain)
        self.assertEqual(back_to_nsis, original_nsis)

    def test_complex_string_with_multiple_escapes(self):
        """Test handling of strings with multiple escape sequences."""
        nsis_text = r'${prodname} is installed.$\r$\nClick $\"OK$\" to continue.'
        unescaped = _unescape_nsis_text(nsis_text)
        self.assertEqual(
            unescaped, '${prodname} is installed.\nClick "OK" to continue.')
        re_escaped = _escape_nsis_text(unescaped)
        self.assertEqual(re_escaped, nsis_text)

    def test_parse_po_string(self):
        """Test PO string literal parsing."""
        self.assertEqual(_parse_po_string('"Hello world"'), 'Hello world')
        self.assertEqual(_parse_po_string('"Line1\\nLine2"'), 'Line1\nLine2')
        self.assertEqual(_parse_po_string('"Say \\"hi\\""'), 'Say "hi"')
        self.assertEqual(_parse_po_string(''), '')
        self.assertEqual(_parse_po_string('  '), '')

    def _parse_po_text(self, content, expected_msgids=None):
        """Write content to a temporary PO file and parse it."""
        with tempfile.NamedTemporaryFile(mode='w', suffix='.po', delete=False, encoding='utf-8') as f:
            f.write(content)
            temp_path = f.name
        try:
            return _parse_po_file(temp_path, expected_msgids)
        finally:
            os.unlink(temp_path)

    def test_parse_po_entry_without_msgctxt_does_not_overwrite(self):
        """Regression test: an entry without msgctxt must not replace the one before it."""
        content = '''msgctxt "nsis:LAST_KEY"
msgid "English"
msgstr "Translated"

msgid "Other string"
msgstr "Other translation"
'''
        self.assertEqual(self._parse_po_text(content),
                         {'nsis:LAST_KEY': 'Translated'})

    def test_parse_po_fuzzy_entry_uses_msgid(self):
        """Fuzzy translations are not used, as with msgfmt."""
        content = '''#, fuzzy
msgctxt "nsis:FUZZY_KEY"
msgid "English"
msgstr "Guess"

msgctxt "nsis:GOOD_KEY"
msgid "English 2"
msgstr "Translated"
'''
        self.assertEqual(self._parse_po_text(content),
                         {'nsis:FUZZY_KEY': 'English',
                          'nsis:GOOD_KEY': 'Translated'})

    def test_parse_po_stale_msgid_skipped(self):
        """Translations for an outdated msgid are skipped."""
        content = '''msgctxt "nsis:CHANGED_KEY"
msgid "Old English"
msgstr "Old translation"

msgctxt "nsis:CURRENT_KEY"
msgid "Current English"
msgstr "Current translation"
'''
        expected = {
            'nsis:CHANGED_KEY': 'New English',
            'nsis:CURRENT_KEY': 'Current English',
        }
        self.assertEqual(self._parse_po_text(content, expected),
                         {'nsis:CURRENT_KEY': 'Current translation'})

    def test_checked_translation_falls_back_on_placeholder_mismatch(self):
        """A translation missing or mangling a placeholder yields English"""
        english = '${prodname} is installed in ${VERSION}.'
        good = '${VERSION}: ${prodname} ok'
        self.assertEqual(_checked_translation('de', 'X', good, english), good)
        for bad in ('A(z) $ el lesz ${VERSION}', '${proname} ${VERSION}',
                    '${prodname} ${prodname} ${VERSION}', 'no placeholders'):
            with self.subTest(bad=bad), \
                    unittest.mock.patch('sys.stderr', new=io.StringIO()) as err:
                self.assertEqual(
                    _checked_translation('hu', 'X', bad, english), english)
                self.assertIn('hu X', err.getvalue())

    def test_parse_nsis_stops_at_autogen_marker(self):
        """Regression test: ensure header parsing stops at auto-generated marker."""
        content = '''/*
Header line 1
Header line 2
*/


; This file is auto-generated by windows/nsis_translations.py
; Do not edit manually.
; Source: po/*.po

!ifdef LANG_ENGLISH
\tLangString TEST_KEY ${LANG_ENGLISH} "Test value"
!endif
'''
        with tempfile.NamedTemporaryFile(mode='w', suffix='.nsh', delete=False, encoding='utf-8') as f:
            f.write(content)
            temp_path = f.name

        try:
            header_lines, lang_strings = _parse_nsis_lang_strings(temp_path)
            # Header should not include the auto-generated comment or anything after it
            header_text = ''.join(header_lines)
            self.assertNotIn('auto-generated', header_text)
            # But it should have parsed the LangStrings
            self.assertIn('LANG_ENGLISH', lang_strings)
            self.assertEqual(
                lang_strings['LANG_ENGLISH']['TEST_KEY'], 'Test value')
        finally:
            os.unlink(temp_path)

    def test_parse_nsis_unescapes_text(self):
        """Regression test: ensure parsing unescapes NSIS text to prevent duplication."""
        content = '''!ifdef LANG_ENGLISH
\tLangString TEST_QUOTES ${LANG_ENGLISH} "Text with $\\"quotes$\\""
\tLangString TEST_NEWLINE ${LANG_ENGLISH} "Line1$\\r$\\nLine2"
!endif
'''
        with tempfile.NamedTemporaryFile(mode='w', suffix='.nsh', delete=False, encoding='utf-8') as f:
            f.write(content)
            temp_path = f.name

        try:
            _header, lang_strings = _parse_nsis_lang_strings(temp_path)
            # Text should be unescaped when read
            self.assertEqual(
                lang_strings['LANG_ENGLISH']['TEST_QUOTES'], 'Text with "quotes"')
            self.assertEqual(
                lang_strings['LANG_ENGLISH']['TEST_NEWLINE'], 'Line1\nLine2')
            # Should not contain NSIS escape sequences
            self.assertNotIn(
                '$\\', lang_strings['LANG_ENGLISH']['TEST_QUOTES'])
            self.assertNotIn(
                '$\\', lang_strings['LANG_ENGLISH']['TEST_NEWLINE'])
        finally:
            os.unlink(temp_path)

    def test_roundtrip_prevents_escape_duplication(self):
        """Regression test: ensure read-write cycle doesn't duplicate escapes."""
        original_escaped = r'Version {VERSION} is installed in $\"{FOLDER}$\".'

        # Simulate reading from file (should unescape)
        unescaped = _unescape_nsis_text(original_escaped)
        self.assertEqual(
            unescaped, 'Version {VERSION} is installed in "{FOLDER}".')

        # Simulate writing back to file (should re-escape)
        re_escaped = _escape_nsis_text(unescaped)
        self.assertEqual(re_escaped, original_escaped)

        # Multiple cycles should not change the result
        for _ in range(5):
            unescaped = _unescape_nsis_text(re_escaped)
            re_escaped = _escape_nsis_text(unescaped)
            self.assertEqual(re_escaped, original_escaped)
            # Should never have doubled escapes like $\$\
            self.assertNotIn('$\\$\\', re_escaped)

    def test_validate_translation_hints_passes_with_valid_hints(self):
        """Test that validation passes when all hints correspond to real strings."""
        # This should not raise any exception since we cleaned up orphaned hints
        validate_translation_hints()

    def test_validate_translation_hints_fails_on_orphaned_hints(self):
        """Test that validation raises RuntimeError for orphaned hints."""
        original_hints = TRANSLATION_HINTS.copy()
        try:
            # Add a fake hint that doesn't correspond to any real LangString
            TRANSLATION_HINTS['FAKE_ORPHANED_KEY'] = 'This hint has no corresponding LangString.'
            with self.assertRaises(RuntimeError) as context:
                validate_translation_hints()
            self.assertIn('FAKE_ORPHANED_KEY', str(context.exception))
        finally:
            # Restore original hints
            TRANSLATION_HINTS.clear()
            TRANSLATION_HINTS.update(original_hints)


def main():
    """CLI entry point for generating NSIS POT and language files."""
    parser = argparse.ArgumentParser(
        description='Generate NSIS translation files from PO sources.')
    parser.add_argument('--pot', action='store_true',
                        help='Generate po/nsis.pot from NSIS English strings.')
    parser.add_argument('--generate', action='store_true',
                        help='Generate NsisInclude/NsisMultiUserLang.nsh from po/*.po.')
    parser.add_argument('--check-keys', action='store_true',
                        help='Check that TRANSLATION_HINTS keys exist in NSIS files.')
    parser.add_argument('--unittest', action='store_true',
                        help='Run unit tests.')
    args = parser.parse_args()

    if args.unittest:
        suite = unittest.TestLoader().loadTestsFromTestCase(TestNsisEscaping)
        runner = unittest.TextTestRunner(verbosity=2)
        result = runner.run(suite)
        return 0 if result.wasSuccessful() else 1

    if args.check_keys:
        validate_keys_usage()
        print('All TRANSLATION_HINTS keys are used in NSIS files.')
        return 0

    if not args.pot and not args.generate:
        parser.print_help()
        return 2

    if args.pot:
        write_nsis_pot()
    if args.generate:
        write_nsis_langfile()

    return 0


if __name__ == '__main__':
    sys.exit(main())