Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,5 @@ jobs:
# tests into skips, and skips still count here.
- name: Guard test count
env:
MIN_TESTS: "1015"
MIN_TESTS: "1146"
run: python .github/scripts/check_test_count.py report.xml ${{ env.MIN_TESTS }}
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ ship under the same number.

---

## [3.4.1] — 2026-08-26

### Fixed

- **The interface opened in Russian whatever language you had chosen.** On Linux the GTK entry point never applied the saved language at all, so the control in Settings had no effect on anything; and on a first run, before there is a choice to apply, both frontends fell back to the Russian default — so a new player anywhere in the world read the setup wizard, the first thing they see, in a language they may not have. A saved choice is honoured now, and a first run takes its language from the operating system instead, falling back to Russian when the system names one the interface does not have. Windows is asked which language its *interface* is in, which is a different setting there from the one dates and numbers are formatted by, and the one that actually answers the question.
- **Changing the language left every open window in the old one.** A widget keeps the string it was built with, so the new language reached nothing already on screen and took hold only at the next launch — which reads as a control that does nothing. The overlay relabels itself now, on both platforms, and on Windows so does the separate window the clipboard hotkey opens — created on demand and then kept, so it outlived the setting that was changed after it.
- **The tray menu was the worst of those, because you cannot close and reopen it.** It is built once at startup, and on Linux it was never translated at all: five English items above a Russian overlay, whatever you had picked. It is translated now and follows a language change on both platforms — and its first entry is written from where the overlay actually is, so it no longer offers to hide a window that is already hidden.
- **The setup wizard forgot what you had typed when you changed its language.** Showing it in a new language means building its pages again, and neither wizard kept what was already in the fields — an API key pasted on the second page, the WoW folder browsed for on the third, both silently gone because of the dropdown on the first. They carry across now.
- **Saving a new language leaves the settings window rebuilt rather than stale.** Reopening it is the rebuild, so that one save closes it; any other save leaves it where it is, with its confirmation where you can read it.

---

## [3.4.0] — 2026-08-23

### Added
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@

---

## [3.4.1] — 2026-08-26

### Исправлено

- **Интерфейс открывался по-русски, какой язык ни выбери.** На Linux GTK-вход сохранённый язык не применял вовсе, так что переключатель в настройках не влиял ни на что; а на первом запуске, когда применять ещё нечего, оба фронтенда откатывались к русскому по умолчанию — и новичок в любой точке мира читал мастер настройки, первое, что он вообще видит, на языке, которого может не знать. Сохранённый выбор теперь соблюдается, а первый запуск берёт язык у операционной системы и остаётся русским, только если система называет язык, которого у интерфейса нет. У Windows спрашивается язык её *интерфейса* — это там отдельная настройка, не та, по которой форматируются даты и числа, и отвечает на вопрос именно она.
- **Смена языка оставляла все открытые окна на прежнем.** Виджет хранит ту строку, с которой был построен, поэтому новый язык не доходил ни до чего, что уже на экране, и вступал в силу только со следующего запуска — а это выглядит как переключатель, который ничего не делает. Оверлей теперь переподписывает себя сам на обеих платформах, а на Windows — и отдельное окно, которое открывает горячая клавиша перевода из буфера обмена: оно создаётся по требованию и потом остаётся жить, переживая настройку, изменённую после него.
- **Хуже всех было меню в трее, потому что его нельзя закрыть и открыть заново.** Оно строится один раз при запуске, а на Linux не переводилось вообще: пять английских пунктов над русским оверлеем, что ни выбери. Теперь переведено и следует за сменой языка на обеих платформах, а первый пункт пишется по тому, где оверлей на самом деле, — и больше не предлагает скрыть уже скрытое окно.
- **Мастер настройки забывал введённое, когда в нём меняли язык.** Показать мастер на новом языке — значит построить его страницы заново, и ни один из двух не сохранял того, что уже было в полях: ключ, вставленный на второй странице, папка WoW, выбранная на третьей, — всё пропадало молча из-за списка на первой. Теперь переносится.
- **Сохранение нового языка оставляет окно настроек перестроенным, а не устаревшим.** Перестроение — это и есть то, что происходит при следующем открытии, поэтому именно такое сохранение окно закрывает; любое другое оставляет его на месте, вместе с подтверждением, которое можно прочитать.

---

## [3.4.0] — 2026-08-23

### Добавлено
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ WoW, so an addon-only setup has no egress at all.
| Cache | SQLite + LRU |
| Build | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) |
| Addon | Lua 5.1, WoW API |
| Tests | 998 tests (pytest) |
| Tests | 1146 tests (pytest) |

## Development

Expand Down
2 changes: 1 addition & 1 deletion README_es.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ alguno.
| Caché | SQLite + LRU |
| Compilación | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) |
| Addon | Lua 5.1, WoW API |
| Tests | 998 tests (pytest) |
| Tests | 1146 tests (pytest) |

## Desarrollo

Expand Down
2 changes: 1 addition & 1 deletion README_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ BabelChat переводит, отправляя текст сообщений
| Кэш | SQLite + LRU |
| Сборка | PyInstaller → .exe (Windows) / AppImage, .deb, .rpm (Linux) |
| Аддон | Lua 5.1, WoW API |
| Тесты | 998 тестов (pytest) |
| Тесты | 1146 тестов (pytest) |

## Разработка

Expand Down
2 changes: 1 addition & 1 deletion addon/BabelChat/BabelChat.toc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## Notes-esES: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r
## Notes-esMX: |cFF33CCFFTraducción de chat en tiempo real — diccionario integrado + overlay con app acompañante.|r
## Author: Andrey Yumashev, Pirson
## Version: 3.4.0
## Version: 3.4.1
## X-License: MIT
## X-Website: https://github.com/Yumash/BabelChat

Expand Down
2 changes: 1 addition & 1 deletion addon/BabelChat/Config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function addonTable.CreateConfigUI()

local version = panel:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
version:SetPoint("TOP", logo, "BOTTOM", 0, -2)
version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.0"))
version:SetText("v" .. (C_AddOns.GetAddOnMetadata(ADDON_NAME, "Version") or "3.4.1"))

-- ════════════════════════════════════
-- SECTION 1: GENERAL
Expand Down
2 changes: 1 addition & 1 deletion app/about_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from app.i18n import tr

VERSION = "3.4.0"
VERSION = "3.4.1"

ABOUT_STYLESHEET = """
QDialog {
Expand Down
36 changes: 34 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,7 @@ def save(self, path: str = CONFIG_FILE) -> None:
@classmethod
def load(cls, path: str = CONFIG_FILE) -> AppConfig:
"""Load config from JSON file, using defaults for missing fields."""
target = Path(path)
for try_path in [target, target.with_suffix(".json.bak")]:
for try_path in _config_candidates(path):
try:
data = json.loads(try_path.read_text(encoding="utf-8"))
_migrate_provider_keys(data, try_path)
Expand All @@ -173,6 +172,39 @@ def load(cls, path: str = CONFIG_FILE) -> AppConfig:
return cls()


def _config_candidates(path: str = CONFIG_FILE) -> list[Path]:
"""Every file `AppConfig.load` will accept a saved config from, in order.

Declared once so that asking "is there a saved config?" and answering
"here is the saved config" cannot come to look at different files.
"""
target = Path(path)
return [target, target.with_suffix(".json.bak")]


def saved_config_exists(path: str = CONFIG_FILE) -> bool:
"""True when a config this build can actually read is on disk.

Deliberately not `os.path.exists(CONFIG_FILE)`, which answers a different
question and gets it wrong in both directions. `load` also reads
`config.json.bak`, so an absent main file does not mean the user has no
saved preferences — and it falls back to defaults on a corrupt one, so a
present main file does not mean any were read. Anything deciding whether
this is a first run has to ask about the same candidates `load` does.

Parses rather than stats, for the corrupt case, but does not migrate: the
migrations write backups of their own, and running them from a question is
not what a question should do.
"""
for candidate in _config_candidates(path):
try:
json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
return True
return False


@dataclass(frozen=True, slots=True)
class ChannelToggle:
"""One switch in the settings window, and everything that depends on it.
Expand Down
108 changes: 107 additions & 1 deletion app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

import locale
import os
import sys
from typing import ClassVar

from app import locales
Expand All @@ -25,7 +28,11 @@ class tr:

@classmethod
def set_language(cls, lang: str) -> None:
cls._lang = lang if lang in ("RU", "EN", "ES") else "RU"
# Against UI_LANGUAGES, not a tuple repeating it: a fourth translation
# added to the table but not here would be resolved by the locale guess
# and then silently clamped back to Russian on the way in, which is the
# first-run bug this module exists to prevent, wearing a new hat.
cls._lang = lang if lang in UI_LANGUAGES else "RU"

@classmethod
def get_language(cls) -> str:
Expand All @@ -44,3 +51,102 @@ def __new__(cls, key: str, **kwargs: object) -> str: # type: ignore[misc]
if kwargs:
text = text.format(**kwargs)
return text


#: Environment variables that state the user's preferred UI language, in the
#: order gettext resolves them: LANGUAGE wins outright, and only then do the
#: LC_* variables and LANG get a say. Getting this order wrong is easy — LANG
#: is the famous one — and it silently ignores whatever LANGUAGE asked for.
_LOCALE_ENV_VARS = ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG")


def _codes_in(value: str) -> list[str]:
"""Language codes named by one locale environment variable.

LANGUAGE holds a colon-separated preference list ("es:ru"); the others hold
a single locale ("es_ES.UTF-8"). Both reduce to the leading language part,
upper-cased, which is what UI_LANGUAGES is keyed by.
"""
codes = []
for item in value.split(":"):
code = item.split(".")[0].split("@")[0].split("_")[0].strip().upper()
if code:
codes.append(code)
return codes


def _windows_ui_language() -> str:
"""The display language Windows itself is set to, as an ISO locale name.

`locale.getlocale()` cannot answer this on Windows: it reports the C
runtime's name for the locale — "Russian_Russia", "English_United States" —
which is not an ISO code and which nothing here can key on. Measured on a
Russian Windows 11: `getlocale()` gives `('Russian_Russia', '1252')`, so
every guess fell through to the default and the first-run language was
Russian for the whole world, which is the bug this was written to fix.

Windows also separates the language the interface is in from the locale
dates and numbers are formatted by — a machine can be English with Russian
formats — and it is the first of those we are trying to match. That is
`GetUserDefaultUILanguage`, not the format locale `getdefaultlocale` reads
(and which is deprecated for removal besides).
"""
if sys.platform != "win32":
return ""
try:
import ctypes

lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage()
except (AttributeError, OSError, ValueError): # not the Windows we expected
return ""
return getattr(locale, "windows_locale", {}).get(lcid, "")


def guess_ui_language(default: str = "RU") -> str:
"""The UI language to open with when nothing has been saved yet.

First run has no preference to honour, and defaulting to Russian for
everyone meant a first-time player anywhere else read the setup wizard in a
language they may not have. The OS locale is the only signal available, so
it decides — falling back to `default` when it is absent, unreadable, or
names a language this build has no UI for.

Only for a genuine first run: once a config file exists it carries a real
choice, and guessing over that replaces it with the machine's opinion.
"""
for env_var in _LOCALE_ENV_VARS:
for code in _codes_in(os.environ.get(env_var, "")):
if code in UI_LANGUAGES:
return code

# Windows sets none of those variables, and its C-runtime locale name is
# not something `_codes_in` can read. Ask Win32 directly before falling
# back to the POSIX path below.
for code in _codes_in(_windows_ui_language()):
if code in UI_LANGUAGES:
return code

try:
loc = locale.getlocale()[0] or ""
except (ValueError, TypeError): # a malformed locale setting, not our problem
return default
for code in _codes_in(loc):
if code in UI_LANGUAGES:
return code
return default


def startup_ui_language(*, config_exists: bool, saved: str) -> str:
"""The language to render in before the user has had a chance to say.

One rule, called by both entry points, because this is exactly the kind of
decision that has drifted between the Qt and GTK frontends every time it
was written twice: a saved choice is honoured, and only a machine that has
never run the app is asked what language it speaks.

`config_exists` is deliberately about the FILE, not about whether the app
is fully set up. The setup wizard also reopens when a provider stops being
configured, and treating that as a first run would let the OS locale
overwrite the language the user picked the last time round.
"""
return saved if config_exists else guess_ui_language()
Loading
Loading