locale: gui: show translation completion percentage in language names
What changed, and why it matters
This commit is a user-interface improvement, not a security fix. It changes the language selection dropdown in Electrum's desktop and mobile-style GUIs so that each language name is followed by an estimated translation completeness percentage (for example, 'Danish (13%)'). The percentages are calculated at build time from translation files and stored in a new stats.json file; the program reads that file at runtime. There is no change to cryptography, wallet handling, networking, or authentication.
No security action required. This is a routine UI/UX enhancement. Reviewers may optionally verify that stats.json is generated correctly during the release build process.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch adds translation-completion statistics to the GUI language picker. A new stats.py build script reads compiled .mo files, extracts a custom X-Electrum-SourceStringCount header added by push_locale.py, counts translated entries, and writes electrum/locale/locale/stats.json. i18n.py gains _get_stats() and get_gui_lang_names() to load stats.json and decorate language names with completion percentages. The QML and Qt settings dialogs are updated to use get_gui_lang_names() instead of the static languages dict. The code tolerates a missing stats.json by falling back to plain language names. No security-sensitive logic is modified.
Changed components
electrum/i18n.pyelectrum/gui/qml/qeconfig.pyelectrum/gui/qt/settings_dialog.pycontrib/locale/build_locale.shcontrib/locale/push_locale.pycontrib/locale/stats.pyInspect captured patch +154 / −12
diff --git a/contrib/locale/build_locale.sh b/contrib/locale/build_locale.sh
index 8439489..cdd72be 100755
--- a/contrib/locale/build_locale.sh
+++ b/contrib/locale/build_locale.sh
@@ -4,6 +4,9 @@
set -e
+CONTRIB_LOCALE="$(dirname "$(realpath "$0" 2> /dev/null || grealpath "$0")")"
+
+
if [[ ! -d "$1" || -z "$2" ]]; then
echo "usage: $0 locale_source_dir locale_dest_dir"
echo " The dirs can match, to build in place."
@@ -28,3 +31,6 @@ for i in *; do
mkdir -p "$dir"
(msgfmt --output-file="$dir/electrum.mo" "$i/electrum.po" || true)
done
+
+echo "running stats.py"
+"$CONTRIB_LOCALE/stats.py"
diff --git a/contrib/locale/push_locale.py b/contrib/locale/push_locale.py
index aeb038f..be2849e 100755
--- a/contrib/locale/push_locale.py
+++ b/contrib/locale/push_locale.py
@@ -91,6 +91,20 @@ cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages
print('Generate template')
subprocess.check_output(cmd)
+# Add a custom PO header entry to messages.pot. This header survives crowdin,
+# and will still be in the translated .po files, and will get compiled into the final .mo files.
+cnt_src_strings = 0
+with open(f"{build_dir}/messages.pot", "r", encoding="utf-8") as f:
+ for line in f.readlines():
+ if line.startswith('msgid '):
+ cnt_src_strings += 1
+with open(f"{build_dir}/messages_customheader.pot", "w", encoding="utf-8") as f:
+ f.write('''msgid ""\n''')
+ f.write('''msgstr ""\n''')
+ f.write(f'''"X-Electrum-SourceStringCount: {cnt_src_strings}"\n''')
+cmd = ["msgcat", "-u", "-o", f"{build_dir}/messages.pot", f"{build_dir}/messages.pot", f"{build_dir}/messages_customheader.pot"]
+print('Add custom header to template')
+subprocess.check_output(cmd)
# prepare uploading to crowdin
os.chdir(os.path.join(project_root, "electrum"))
diff --git a/contrib/locale/stats.py b/contrib/locale/stats.py
new file mode 100755
index 0000000..072e6c2
--- /dev/null
+++ b/contrib/locale/stats.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2026 The Electrum developers
+# Distributed under the MIT software license, see the accompanying
+# file LICENCE or http://www.opensource.org/licenses/mit-license.php
+#
+#
+# This generates a 'stats.json' file containing some statistics about translation completeness.
+
+import gettext
+import glob
+import json
+import os
+
+PROJECT_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
+LOCALE_DIR = os.path.join(PROJECT_ROOT, "electrum", "locale", "locale")
+
+
+if __name__ == '__main__':
+ catalog_size = {} # type: dict[str, int]
+ source_string_count = None
+ # - calc stats
+ files_list = glob.glob(f"{LOCALE_DIR}/*/LC_MESSAGES/*.mo")
+ for fname in files_list:
+ lang_code = os.path.basename(os.path.dirname(os.path.dirname(fname)))
+ try:
+ t = gettext.translation('electrum', LOCALE_DIR, languages=[lang_code])
+ except OSError as e:
+ raise Exception(f"cannot find or parse .mo file matching {fname!r}") from e
+ # calc catalog size of translated strings
+ catalog_size[lang_code] = len(t._catalog)
+ # same SourceStringCount header should be present in all .mo files:
+ t_info = t.info()
+ try:
+ ss_cnt = int(t_info["x-electrum-sourcestringcount"])
+ except Exception as e:
+ raise Exception(
+ f"missing or malformed 'x-electrum-sourcestringcount' header, for {lang_code!r}.\n"
+ f"found {t_info}"
+ ) from e
+ if source_string_count is None:
+ source_string_count = ss_cnt
+ elif source_string_count != ss_cnt:
+ raise Exception(
+ f"inconsistent 'x-electrum-sourcestringcount' headers! "
+ f"prev_cnt={source_string_count}, new_cnt={ss_cnt} (for lang={lang_code})")
+ # - convert to json data. example:
+ # {
+ # "source_string_count": 9999,
+ # "translations": {
+ # "de_DE": {
+ # "string_count": 400,
+ # },
+ # ...
+ # }
+ # }
+ json_data = {
+ "source_string_count": source_string_count,
+ "translations": {},
+ }
+ for lang_code in catalog_size:
+ json_data["translations"][lang_code] = {}
+ json_data["translations"][lang_code]["string_count"] = catalog_size[lang_code]
+ # - write json to disk
+ with open(f"{LOCALE_DIR}/stats.json", "w", encoding="utf-8") as f:
+ json_str = json.dumps(
+ json_data,
+ indent=4,
+ sort_keys=True
+ )
+ f.write(json_str)
+ print(f"done. created file '{LOCALE_DIR}/stats.json'")
diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py
index 8c0607b..d4ca6b9 100644
--- a/electrum/gui/qml/qeconfig.py
+++ b/electrum/gui/qml/qeconfig.py
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRegularExpression
from electrum.bitcoin import TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
-from electrum.i18n import set_language, languages
+from electrum.i18n import set_language, get_gui_lang_names
from electrum.logging import get_logger
from electrum.util import base_unit_name_to_decimal_point
from electrum.gui import messages
@@ -52,7 +52,7 @@ class QEConfig(AuthMixin, QObject):
@language.setter
def language(self, language):
- if language not in languages:
+ if language not in get_gui_lang_names():
return
if self.config.LOCALIZATION_LANGUAGE != language:
self.config.LOCALIZATION_LANGUAGE = language
@@ -62,12 +62,9 @@ class QEConfig(AuthMixin, QObject):
languagesChanged = pyqtSignal()
@pyqtProperty('QVariantList', notify=languagesChanged)
def languagesAvailable(self):
- # sort on translated languages, then re-add Default on top
- langs = copy.deepcopy(languages)
- default = langs.pop('')
- langs_sorted = sorted(list(map(lambda x: {'value': x[0], 'text': x[1]}, langs.items())), key=lambda x: x['text'])
- langs_sorted.insert(0, {'value': '', 'text': default})
- return langs_sorted
+ langs = get_gui_lang_names()
+ langs_list = list(map(lambda x: {'value': x[0], 'text': x[1]}, langs.items()))
+ return langs_list
termsOfUseChanged = pyqtSignal()
@pyqtProperty(bool, notify=termsOfUseChanged)
diff --git a/electrum/gui/qt/settings_dialog.py b/electrum/gui/qt/settings_dialog.py
index 53010d2..8e727a2 100644
--- a/electrum/gui/qt/settings_dialog.py
+++ b/electrum/gui/qt/settings_dialog.py
@@ -31,7 +31,7 @@ from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (QComboBox, QTabWidget, QDialog, QSpinBox, QCheckBox, QLabel,
QVBoxLayout, QGridLayout, QLineEdit, QWidget, QHBoxLayout, QSlider)
-from electrum.i18n import _, languages
+from electrum.i18n import _, get_gui_lang_names
from electrum import util
from electrum.util import base_units_list, event_listener
@@ -76,8 +76,9 @@ class SettingsDialog(QDialog, QtEventListener):
# language
lang_label = HelpLabel.from_configvar(self.config.cv.LOCALIZATION_LANGUAGE)
lang_combo = QComboBox()
- lang_combo.addItems(list(languages.values()))
- lang_keys = list(languages.keys())
+ _languages = get_gui_lang_names()
+ lang_combo.addItems(list(_languages.values()))
+ lang_keys = list(_languages.keys())
lang_cur_setting = self.config.LOCALIZATION_LANGUAGE
try:
index = lang_keys.index(lang_cur_setting)
@@ -88,7 +89,7 @@ class SettingsDialog(QDialog, QtEventListener):
for w in [lang_combo, lang_label]: w.setEnabled(False)
def on_lang(x):
- lang_request = list(languages.keys())[lang_combo.currentIndex()]
+ lang_request = list(_languages.keys())[lang_combo.currentIndex()]
if lang_request != self.config.LOCALIZATION_LANGUAGE:
self.config.LOCALIZATION_LANGUAGE = lang_request
self.need_restart = True
diff --git a/electrum/i18n.py b/electrum/i18n.py
index 04389d2..923d82e 100644
--- a/electrum/i18n.py
+++ b/electrum/i18n.py
@@ -23,6 +23,7 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import functools
+import json
import os
import string
from typing import Optional
@@ -168,3 +169,54 @@ languages = {
'zh_TW': 'Chinese Traditional',
}
assert '' in languages
+
+
+def get_gui_lang_names(*, show_completion_percent: bool = True) -> dict[str, str]:
+ """Returns a lang_code -> lang_name mapping, sorted.
+
+ If show_completion_percent is True, lang_name includes a % estimate for translation completeness.
+ """
+ # calc catalog sizes
+ if show_completion_percent:
+ stats = _get_stats()
+ # sort ("Default" first, then "English", then lexicographically sorted names)
+ languages_copy = languages.copy()
+ lang_pair_default = ("", languages_copy.pop("")) # pop "Default"
+ lang_pair_english = ("en_UK", languages_copy.pop("en_UK")) # pop "English"
+ lang_pairs_sorted = sorted(languages_copy.items(), key=lambda x: x[1])
+ # fancy names
+ gui_lang_names = {} # type: dict[str, str]
+ gui_lang_names[lang_pair_default[0]] = lang_pair_default[1]
+ gui_lang_names[lang_pair_english[0]] = lang_pair_english[1]
+ for lang_code, lang_name in lang_pairs_sorted:
+ if show_completion_percent and stats:
+ source_str_cnt = max(stats["source_string_count"], 1) # avoid div-by-zero
+ try:
+ lang_data = stats["translations"][lang_code]
+ except KeyError as e:
+ _logger.warning(f"missing language from stats.json: {e!r}")
+ catalog_percent = "??"
+ else:
+ translated_str_cnt = lang_data["string_count"]
+ catalog_percent = round(100 * translated_str_cnt / source_str_cnt)
+ gui_lang_names[lang_code] = f"{lang_name} ({catalog_percent}%)"
+ else:
+ gui_lang_names[lang_code] = lang_name
+ return gui_lang_names
+
+
+_stats = None
+def _get_stats() -> dict:
+ global _stats
+ if _stats is None:
+ fname = f"{LOCALE_DIR}/stats.json"
+ try:
+ with open(fname, "r", encoding="utf-8") as f:
+ text = f.read()
+ except OSError as e: # we tolerate the file missing
+ # This can happen e.g. when running from git clone if user did not run build_locale.sh.
+ _logger.info(f"failed to open stats file {fname!r} - built locale (translations) missing??: {e!r}")
+ _stats = {}
+ else: # found file. if it is there, it MUST parse correctly
+ _stats = json.loads(text)
+ return _stats
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.