diff --git a/CHANGELOG.md b/CHANGELOG.md index 1576e7f..6241470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3327,3 +3327,92 @@ densest frames possible. the base36 count fields are 3 chars regardless of how many frames exist. **Outcome:** DONE. + +## 58. i18n phase 1: BAL translation layer (Electrum first, then BAL) + +**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`) + +**Goal (owner request):** make the whole plugin translatable, starting with +Italian, so that BAL follows the language chosen in Electrum. This phase adds +the mechanism only: nothing visible changes yet. + +**What changed:** + +- New `bal/i18n.py`, BAL's own gettext layer (domain `bal`, catalogs in + `bal/locale//LC_MESSAGES/bal.mo`, read with `plugin.read_file()` so it + works from the zip and from a development checkout): + - `_()` looks a text up in Electrum's catalog first, then in BAL's, then + returns the English source (owner's decision D7: BAL reads like Electrum, + and Electrum's translations would take over if BAL ever became an internal + plugin). BAL translations whose `{}` fields differ from the source are + rejected with the same rules as Electrum's. + - `N_()` marks texts defined at import time, translated when shown. + - `init_from_config()` repeats Electrum's own language choice (Preferences > + Language, else the supported system language, else English) once, in the + Qt `Plugin.__init__`. A missing or broken catalog never stops the plugin. +- `common.py`, `theme.py`, `core/will.py`, `core/willexecutors.py` now import + `_` from `bal.i18n`. The CLI keeps Electrum's translator and stays English, + like Electrum's own CLI. + +**Verification:** new `tests/test_i18n.py`; results in entry 59. + +**Outcome:** DONE (delivered in one test ZIP together with entry 59). + +## 59. i18n phase 2: translatable texts and language-neutral stored data + +**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`) + +**Goal:** make every user-visible text of the Qt GUI extractable and +translatable, without changing the English UI, and make sure no stored data +depends on the GUI language. + +**What changed:** + +- Texts built with f-strings or `.format()` inside `_()`, or glued together + from pieces, are now whole sentences with `{}` fields (Ruff INT clean). +- Class-level texts (list headers, wizard titles/messages, help texts, + download error messages, status labels) are marked with `N_()` and + translated when shown: class bodies run before the catalog is loaded. +- Helpers no longer translate their argument (`add_widget`, `get_window_title`, + `msg_set_status`, the will-detail `qlabel`): callers pass translated text. + This also stops heir names and URLs from going through the translator. +- Newly translatable: the settings help texts, the wizard download options, + the build report rows, the Ping/Select menu buttons, the QR size presets + (marked in `common.QR_PRESET_LABELS`: `core/qrtransfer.py` stays free of + Electrum imports because the Android reader ships a copy of it), and the + "(reminder i/n)" suffix of the calendar events (`build_ics_reminders` gained + `reminder_suffix`, English by default for the CLI). +- **Stored status history:** `WillItem.set_status` now always writes the + English label; the new `format_status_history()` translates it for display + (lists and will detail) and keeps unknown tokens as they are, so histories + saved by older versions (e.g. `New.Firmato.Pushed`) still display fine. +- **Calendar defaults:** `EVENT_SUMMARY` / `EVENT_DESCRIPTION` defaults follow + the GUI language (`BalConfig(..., translatable=True)`), while the English + original is what gets stored; a text written by the user is kept as it is. +- **Not translated on purpose:** `HISTORY_LABEL` (used by + `Util._label_matches_history` to recognise BAL's own local transactions), + the "BAL Invalidate/Inheritance transaction" wallet labels, + `heirs.TRANSACTION_LABEL` and the will-executor "info" fallback stored in the + config. +- Two small visible fixes: the will detail showed "Transaction fees::" and + "Status::" with a double colon. +- `pyproject.toml`: Ruff now also checks the `INT` (gettext) rules. + +**Verification (Electrum 4.8.2, Python 3.14, offscreen):** + +- `tests/test_i18n.py`: 20 passed (lookup order, rejected `{}` fields, + missing/broken catalogs, language fallback, English status storage and its + display translation, translatable config defaults, reminder suffix, QR + preset labels). +- Release-gate batch (as `make-release.sh`): 403 passed, 1 failed; the other + offline test files: 213 passed. Identical to the baseline taken before the + change; the only failures are pre-existing (`test_e5` and + `test_reproduce_none_type` need the local `tests/karen7` wallet, + `test_import_start_stop_scan_signal_wiring` fails on `main` too, + `test_gui_export_dialogs.py` hangs on `main` too). +- `smoke_test.py`, `build_zip.py` and `external_zip_test.py`: OK. +- Ruff: INT clean; no new errors (the only one left is the pre-existing + `dialogs.py` I001). +- Babel extraction (GUI + core): 424 strings (about 3,000 words), was 323. + +**Outcome:** DONE, pending the owner's test of the ZIP. diff --git a/PLAN_I18N.md b/PLAN_I18N.md index f218a92..df87d02 100644 --- a/PLAN_I18N.md +++ b/PLAN_I18N.md @@ -225,6 +225,7 @@ Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/ - mark the English defaults with `N_()`; - when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label); - the tokens must survive translation. + - **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history). - **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`. - Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule. - `make-release.sh` treats Ruff errors as blocking, so INT must end up clean. diff --git a/bal/cli/controller.py b/bal/cli/controller.py index 6e96ac7..7874ac3 100644 --- a/bal/cli/controller.py +++ b/bal/cli/controller.py @@ -91,17 +91,17 @@ def _user_facing(e): _( "In the inheritance process, the entire wallet will always be " "fully emptied. Your settings require an adjustment of the " - f"amounts: {e}" - ) + "amounts: {}" + ).format(e) ) if isinstance(e, heirs_mod.WillExecutorFeeTooHighException): - return UserFacingException(_(f"Will-executor fee too high: {e}")) + return UserFacingException(_("Will-executor fee too high: {}").format(e)) if isinstance(e, heirs_mod.BalanceTooLowException): return UserFacingException(str(e)) if isinstance(e, heirs_mod.HeirAmountIsDustException): return UserFacingException(str(e)) if isinstance(e, heirs_mod.NotAnAddress): - return UserFacingException(_(f"not an address, {e}")) + return UserFacingException(_("not an address, {}").format(e)) if isinstance(e, heirs_mod.AmountNotValid): return UserFacingException(str(e)) if isinstance(e, heirs_mod.LocktimeNotValid): diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 75cdc52..066b68c 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -32,6 +32,8 @@ from electrum.plugin import BasePlugin from electrum.transaction import tx_from_any from electrum.util import classproperty +from ..i18n import N_, _ + _logger = get_logger(__name__) @@ -134,12 +136,20 @@ class BalConfig: Wraps ``config.get`` / ``config.set_key`` and supplies a default value when the key is missing. + + ``translatable=True`` is for a default *text* (marked with ``N_()``) that + should follow the GUI language. The English default is what gets stored, + and :meth:`get` translates it when it is read, so the stored value never + depends on the GUI language. A text the user wrote is returned unchanged. + Only for texts that are shown: a text that is also used to recognise + stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable. """ - def __init__(self, config, name, default): + def __init__(self, config, name, default, translatable=False): self.config = config self.name = name self.default = default + self.translatable = translatable def get(self, default=None): """Return the stored value, falling back to ``default`` then ``self.default``.""" @@ -149,10 +159,20 @@ class BalConfig: v = default else: v = self.default + if self.translatable and v == self.default: + return _(v) return v + def localized_default(self): + """Return the default value, translated if the setting is translatable.""" + return _(self.default) if self.translatable else self.default + def set(self, value, save=True): """Persist ``value`` for this key.""" + if self.translatable and value == self.localized_default(): + # The translated default is stored as its English original, so + # it keeps following the GUI language (see get()). + value = self.default self.config.set_key(self.name, value, save=save) @@ -243,6 +263,10 @@ class BalPlugin(BasePlugin): # the wallet's local history. May contain the "{willexecutor}" token, # which is replaced with the will-executor URL of each will item at # save time. + # Deliberately NOT translatable: Util._label_matches_history() uses + # this text to recognise BAL's own local transactions (stale-history + # cleanup, spendable UTXOs). A label that changed with the GUI + # language would no longer match the transactions saved before. self.HISTORY_LABEL = BalConfig( config, "bal_history_label", @@ -328,13 +352,20 @@ class BalPlugin(BasePlugin): self.WELIST_SERVER = BalConfig( config, "bal_welist_server", "https://welist.bitcoin-after.life/" ) + # Calendar (.ics) texts: only shown, never used to recognise data, so + # their defaults follow the GUI language (translatable=True). The + # $tokens must survive translation. self.EVENT_DESCRIPTION = BalConfig( config, "bal_event_description", - "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", + N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"), + translatable=True, ) self.EVENT_SUMMARY = BalConfig( - config, "bal_event_summary", "BAL -Will execution of $wallet_name" + config, + "bal_event_summary", + N_("BAL -Will execution of $wallet_name"), + translatable=True, ) # Default will-executor servers, keyed by network. These addresses are diff --git a/bal/core/reminders.py b/bal/core/reminders.py index db8990e..18af267 100644 --- a/bal/core/reminders.py +++ b/bal/core/reminders.py @@ -147,6 +147,7 @@ def build_ics_reminders( num_reminders: int = 3, now: Optional[datetime] = None, threshold: Optional[datetime] = None, + reminder_suffix: str = "(reminder {idx}/{total})", ) -> Optional[str]: """Build the ``.ics`` content with one VEVENT per reminder date. @@ -169,6 +170,10 @@ def build_ics_reminders( num_reminders: requested reminder count (ADVANCED mode only). now: "today" reference; defaults to ``datetime.now()``. threshold: check-alive date (ADVANCED mode only; required there). + reminder_suffix: text appended to each event summary, with the + ``{idx}`` and ``{total}`` fields. This module stays free of + Electrum imports, so the GUI passes it already translated; the + English default keeps the CLI output unchanged. Returns: The ``.ics`` content string, or ``None`` when no reminder falls in the @@ -210,7 +215,9 @@ def build_ics_reminders( # The visible date of this event: "offset" days before the deadline. event_dt = format_time(locktime - timedelta(days=offset)) # Suffix the summary so the N events are easy to tell apart. - event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})") + event_summary = ical_escape( + "{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=total)) + ) lines.extend([ "BEGIN:VEVENT", # Offset in the UID keeps each event unique (no merging). diff --git a/bal/core/will.py b/bal/core/will.py index 71d7ff1..83bad97 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -29,7 +29,6 @@ The status flags themselves (the source of truth) stay here; only the mapping from datetime import datetime, timezone from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL -from electrum.i18n import _ from electrum.logging import Logger, get_logger from electrum.transaction import ( PartialTransaction, @@ -44,6 +43,7 @@ from electrum.util import ( bfh, ) +from ..i18n import N_, _ from .heirs import WillExecutorFeeTooHighException from .util import Util, copy_structure from .willexecutors import Willexecutors @@ -1273,6 +1273,34 @@ class Will: +def format_status_history(status: str) -> str: + """Return the saved status history of a will item in the GUI language. + + ``WillItem.status`` is a dot-separated history such as + ``".Signed.Pushed.NOT Valid"``. It is stored in English (see + ``WillItem.set_status``) and translated only here, for display. A token + that is not a known English label is shown unchanged: this keeps working + the histories written by older versions, which stored the label already + translated (for example ``"Firmato"``). + """ + labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()} + labels.update(WillItem.LEGACY_STATUS_LABELS) + + def translate(token): + # "ERROR!!!" is glued to the end of the history when an item has no + # id (see WillItem.__init__): keep it, translate what comes before. + suffix = "" + if token.endswith("ERROR!!!"): + token, suffix = token[: -len("ERROR!!!")], "ERROR!!!" + if token in labels: + token = _(token) + elif token.startswith("NOT ") and token[len("NOT "):] in labels: + token = _("NOT {}").format(_(token[len("NOT "):])) + return token + suffix + + return ".".join(translate(token) for token in status.split(".")) + + class WillItem(Logger): # Default status flags for an inheritance transaction. # Each entry maps an internal status key to [human-readable label, default @@ -1286,28 +1314,37 @@ class WillItem(Logger): # * "UPDATED" was added: the transaction was spendable AND valid, and a new # transaction replaces it while keeping the SAME locktime and SAME heirs. # UPDATED keeps the VALID flag (see set_status). + # + # The labels are marked with N_() but stay English here: they are written + # into the saved status history (see set_status) and translated only when + # shown, by format_status_history(). STATUS_DEFAULT = { - "ANTICIPATED": ["Anticipated", False], - "BROADCASTED": ["Broadcasted", False], - "CHECKED": ["Checked", False], - "CHECK_FAIL": ["Check Failed", False], - "COMPLETE": ["Signed", False], - "CONFIRMED": ["Confirmed", False], - "ERROR": ["Error", False], - "EXPIRED": ["Expired", False], - "EXPORTED": ["Exported", False], - "IMPORTED": ["Imported", False], - "INVALIDATED": ["Invalidated", False], - "MEMPOOL": ["Mempool", False], - "PUSH_FAIL": ["Push failed", False], - "PUSHED": ["Pushed", False], - "PARTIALLY_SIGNED": ["Partially Signed", False], - "REPLACED": ["Replaced", False], - "RESTORED": ["Restored", False], - "UPDATED": ["Updated", False], - "VALID": ["Valid", True], + "ANTICIPATED": [N_("Anticipated"), False], + "BROADCASTED": [N_("Broadcasted"), False], + "CHECKED": [N_("Checked"), False], + "CHECK_FAIL": [N_("Check Failed"), False], + "COMPLETE": [N_("Signed"), False], + "CONFIRMED": [N_("Confirmed"), False], + "ERROR": [N_("Error"), False], + "EXPIRED": [N_("Expired"), False], + "EXPORTED": [N_("Exported"), False], + "IMPORTED": [N_("Imported"), False], + "INVALIDATED": [N_("Invalidated"), False], + "MEMPOOL": [N_("Mempool"), False], + "PUSH_FAIL": [N_("Push failed"), False], + "PUSHED": [N_("Pushed"), False], + "PARTIALLY_SIGNED": [N_("Partially Signed"), False], + "REPLACED": [N_("Replaced"), False], + "RESTORED": [N_("Restored"), False], + "UPDATED": [N_("Updated"), False], + "VALID": [N_("Valid"), True], } + # Labels found in histories saved by older versions but no longer + # written: "New" opened every history (e.g. tests/samanta7). + # format_status_history() still translates them. + LEGACY_STATUS_LABELS = (N_("New"),) + def set_status(self, status, value=True): """Set a status flag and apply the related side effects. @@ -1341,7 +1378,11 @@ class WillItem(Logger): if self.STATUS[status][1] == bool(value): return None - self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0])) + # The history is saved in the wallet, in exported files and in QR + # transfers, so it is always written in English: the saved data must + # not depend on the GUI language (older versions wrote the translated + # label). format_status_history() translates it when it is shown. + self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0]) self.STATUS[status][1] = bool(value) if value: # NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list, diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index eaea558..54bbec0 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -22,10 +22,10 @@ from typing import Any from aiohttp import ClientResponse from electrum import bitcoin, constants from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC -from electrum.i18n import _ from electrum.logging import get_logger from electrum.network import Network +from ..i18n import _ from .plugin_base import BalPlugin, get_version # Per-request timeout (seconds) for interactive operations (ping / info / diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index b4e9896..e7d3e66 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -52,7 +52,6 @@ from electrum.gui.qt.util import ( read_QPixmap_from_bytes, webopen, ) -from electrum.i18n import _ from electrum.logging import get_logger from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError from electrum.payment_identifier import PaymentIdentifier @@ -137,6 +136,7 @@ from ...core.will import ( WillExpiredException, WillItem, WillPostponedException, + format_status_history, ) from ...core.willexecutors import ( # noqa: F401 Willexecutors, @@ -144,6 +144,9 @@ from ...core.willexecutors import ( # noqa: F401 is_tor_active, ) +# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py). +from ...i18n import N_, _ + # --- Presentation helpers --- from .theme import ( server_status_text, @@ -161,6 +164,17 @@ from .window_utils import ( _logger = get_logger(__name__) +# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here +# because qrtransfer.py must stay free of Electrum imports (the Android reader +# ships a copy of it). The combos show them with _(label); +# tests/test_i18n.py checks that this list matches CHUNK_PRESETS. +QR_PRESET_LABELS = ( + N_("Small - ~150 bytes/QR (low-res cameras)"), + N_("Medium - ~400 bytes/QR"), + N_("Large - ~900 bytes/QR"), + N_("XL - ~1800 bytes/QR (high-res cameras)"), +) + class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming) _type = bool @@ -178,13 +192,30 @@ class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal namin def add_widget(grid, label, widget, row, help_): - grid.addWidget(QLabel(_(label)), row, 0) + """Add a ``label | widget | help button`` row to ``grid``. + + ``label`` and ``help_`` must already be translated by the caller: a + variable passed to _() cannot be extracted into the catalog. + """ + grid.addWidget(QLabel(label), row, 0) grid.addWidget(widget, row, 1) grid.addWidget(HelpButton(help_), row, 2) +def translated_headers(headers): + """Return a translated copy of a list's ``headers`` class attribute. + + The column headers are class attributes marked with N_(): a class body + runs at import time, before the catalog is loaded, so they are + translated here, each time the headers are (re)built. + """ + return {column: _(text) for column, text in headers.items()} + + + + def log_error(exec_info, window=None): """Log an error and optionally show it. @@ -222,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter): filter_ = "All files (*)" filename = getSaveFileName( parent=electrum_window, - title=_("Select file to save your {}".format(title)), + title=_("Select file to save your {}").format(title), filename="BALplugin_{}_{}_{}".format( BalPlugin.chainname, str(electrum_window.wallet), title ), @@ -237,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter): electrum_window.show_critical(str(e)) else: electrum_window.show_message( - _("Your {0} were exported to '{1}'".format(title, str(filename))) + _("Your {0} were exported to '{1}'").format(title, str(filename)) ) diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 13f40cb..99d4f26 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -62,6 +62,7 @@ from .calendar import BalCalendarButton from .common import ( HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, + N_, AmountException, Any, BalTimestamp, @@ -322,7 +323,9 @@ class BalWizardWidget(QWidget): self._bal_parent = parent self.on_next = on_next self.on_cancel = on_cancel - self.titleLabel = QLabel(self.title) + # title/message are class attributes marked with N_(): translate them + # here, when shown (a class body runs before the catalog is loaded). + self.titleLabel = QLabel(_(self.title) if self.title else "") self.vbox.addWidget(self.titleLabel) self.messageLabel = QLabel(_(self.message)) self.vbox.addWidget(self.messageLabel) @@ -377,8 +380,8 @@ class BalWizardWidget(QWidget): class BalWizardHeirsWidget(BalWizardWidget): - title = "Bitcoin After Life Heirs" - message = ( + title = N_("Bitcoin After Life Heirs") + message = N_( "Please add your heirs\n remember that 100% of wallet balance will be spent" ) @@ -416,18 +419,18 @@ class BalWizardHeirsWidget(BalWizardWidget): class BalWizardWEDownloadWidget(BalWizardWidget): - title = _("Bitcoin After Life Will-Executors") - message = _("Choose willexecutors download method") + title = N_("Bitcoin After Life Will-Executors") + message = N_("Choose willexecutors download method") def get_content(self): # question = QLabel() self.combo = QComboBox() self.combo.addItems( [ - "Automatically download and select willexecutors", - "Only download willexecutors list", - "Import willexecutor list from file", - "Manual", + _("Automatically download and select willexecutors"), + _("Only download willexecutors list"), + _("Import willexecutor list from file"), + _("Manual"), ] ) # heir_name.setFixedWidth(32 * char_width_in_lineedit()) @@ -524,8 +527,8 @@ class BalWizardWEDownloadWidget(BalWizardWidget): class BalWizardWEWidget(BalWizardWidget): - title = "Bitcoin After Life Will-Executors" - message = _("Configure and select your willexecutors") + title = N_("Bitcoin After Life Will-Executors") + message = N_("Configure and select your willexecutors") def get_content(self): # Lazy import to avoid a dialogs<->lists import cycle. @@ -544,8 +547,8 @@ class BalWizardWEWidget(BalWizardWidget): class BalWizardLocktimeAndFeeWidget(BalWizardWidget): - title = "Bitcoin After Life Will Settings" - message = _("") + title = N_("Bitcoin After Life Will Settings") + message = "" def get_content(self): widget = QWidget() @@ -748,7 +751,7 @@ class BalBuildWillDialog(BalDialog): return txs = None _logger.debug("close plugin phase 1 started") - varrow = self.msg_set_status("Checking variables") + varrow = self.msg_set_status(_("Checking variables")) try: self.bal_window.init_class_variables() except CheckAliveError as cae: @@ -766,9 +769,9 @@ class BalBuildWillDialog(BalDialog): "during phase1 CAE: {}, Continue to invalidate".format(cae) ) self.msg_set_status( - "Checking variables", varrow, - "Check Alive Threshold Passed: you have to Invalidate " - "your old Will", + _("Checking variables"), varrow, + _("Check Alive Threshold Passed: you have to Invalidate " + "your old Will"), self.COLOR_ERROR, ) else: @@ -776,7 +779,7 @@ class BalBuildWillDialog(BalDialog): return None, tx except NoHeirsException: self.msg_set_status( - "Checking variables", varrow, self.msg_alert("No Heirs") + _("Checking variables"), varrow, self.msg_alert(_("No Heirs")) ) return "no_heirs", None except Exception as e: @@ -799,18 +802,20 @@ class BalBuildWillDialog(BalDialog): max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(), ) _logger.debug("variables ok") - self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) + self.msg_set_status(_("Checking variables"), varrow, _("Ok"), self.COLOR_OK) except AmountException: self.msg_set_checking( self.msg_warning( - "In the inheritance process, " - + "the entire wallet will always be fully emptied. \n" - + "Your settings require an adjustment of the amounts" + _( + "In the inheritance process, " + "the entire wallet will always be fully emptied. \n" + "Your settings require an adjustment of the amounts" + ) ) ) except WillExecutorFeeTooHighException as e: self.msg_set_checking( - self.msg_warning(f"Will-executor fee too high: {e}") + self.msg_warning(_("Will-executor fee too high: {}").format(e)) ) self.msg_set_checking() @@ -833,7 +838,7 @@ class BalBuildWillDialog(BalDialog): # makes the CHECK button and the WIZARD behave identically and fixes # the missing-label bug (#03). _logger.debug("expired") - self.msg_set_checking("Expired") + self.msg_set_checking(_("Expired")) return "invalidate_classic", None except WillPostponedException as e: # An already signed/sent will is being postponed. Like an expired @@ -856,7 +861,7 @@ class BalBuildWillDialog(BalDialog): ) except NoHeirsException: _logger.debug("no heirs") - self.msg_set_checking("No Heirs") + self.msg_set_checking(_("No Heirs")) except NotCompleteWillException as e: _logger.debug(f"not complete {e} true") message = False @@ -1051,9 +1056,11 @@ class BalBuildWillDialog(BalDialog): dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT]) for hid, dust_amount in dust_heirs.items(): self.msg_set_status( - f"{_('Heir')} {hid}", + _("Heir {}").format(hid), None, - f"{dust_amount} is DUST - excluded (amount below dust limit)", + _("{} is DUST - excluded (amount below dust limit)").format( + dust_amount + ), self.COLOR_WARNING, ) @@ -1223,14 +1230,14 @@ class BalBuildWillDialog(BalDialog): for i in range(secs, 0, -1): if self._stopping: return - wait_row = self.msg_edit_row(_(f"Please wait {i}secs"), wait_row) + wait_row = self.msg_edit_row(_("Please wait {}secs").format(i), wait_row) time.sleep(1) self.msg_del_row(wait_row) def loop_broadcast_invalidating(self, tx): if self._stopping: return - self.msg_set_invalidating("Broadcasting") + self.msg_set_invalidating(_("Broadcasting")) try: tx.add_info_from_wallet(self.bal_window.wallet) self.network.run_from_another_thread(tx.add_info_from_network(self.network)) @@ -1341,7 +1348,7 @@ class BalBuildWillDialog(BalDialog): done["count"] += 1 # Show the per-server result (Ok/Ko) in bold + color so the # outcome stands out, keeping the server URL in normal weight. - result = self.msg_ok("Ok") if ok else self.msg_error("Ko") + result = self.msg_ok(_("Ok")) if ok else self.msg_error(_("Ko")) self.msg_edit_row("{} : {}".format(url, result)) self.msg_set_pushing(_status_line()) @@ -1393,8 +1400,11 @@ class BalBuildWillDialog(BalDialog): if self._stopping: return row = self.msg_edit_row( - "checking {} - {} : {}".format( - self.bal_window.willitems[wid].we["url"], wid, "Waiting" + "{} : {}".format( + _("checking {} - {}").format( + self.bal_window.willitems[wid].we["url"], wid + ), + _("Waiting"), ) ) w = self.bal_window.willitems[wid] @@ -1407,9 +1417,10 @@ class BalBuildWillDialog(BalDialog): checked = self.bal_window.willitems[wid].get_status("CHECKED") result = self.msg_ok(checked) if checked else self.msg_error(checked) row = self.msg_edit_row( - "checked {} - {} : {}".format( - self.bal_window.willitems[wid].we["url"], - wid, + "{} : {}".format( + _("checked {} - {}").format( + self.bal_window.willitems[wid].we["url"], wid + ), result, ), row, @@ -1457,7 +1468,7 @@ class BalBuildWillDialog(BalDialog): raise Exception("not tx") except Exception as e: (f"exception:{e}") - self.msg_set_invalidating(f"Error: {e}") + self.msg_set_invalidating(_("Error: {}").format(e)) raise Exception("Impossible to sign") from e def on_success_invalidate(self, success): @@ -1825,8 +1836,11 @@ class BalBuildWillDialog(BalDialog): basic_mode = self.bal_window.bal_plugin.is_basic_mode() if basic_mode: - raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default - raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default + # Factory defaults, in the GUI language (see BalConfig). + raw_description = ( + self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default() + ) + raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default() threshold = None num_reminders = 3 else: @@ -1863,6 +1877,7 @@ class BalBuildWillDialog(BalDialog): version=self.bal_window.bal_plugin.version, num_reminders=num_reminders, threshold=threshold, + reminder_suffix=_("(reminder {idx}/{total})"), ) except Exception as e: _logger.error(f"failed to generate .ics: {e}") @@ -2006,7 +2021,7 @@ class BalBuildWillDialog(BalDialog): def on_error_phase1(self, error): self.bal_window.update_all() a, b, c = error - self.msg_edit_row(self.msg_error(f"Error: {b}")) + self.msg_edit_row(self.msg_error(_("Error: {}").format(b))) import traceback _logger.error(f"error phase1: {b}\n{''.join(traceback.format_exception(a, b, c))}") button=QPushButton(_("Close")) @@ -2017,7 +2032,7 @@ class BalBuildWillDialog(BalDialog): def on_error_phase2(self, error): self.bal_window.upade_all() a, b, c = error - self.msg_edit_row(self.msg_error(f"Error: {b}")) + self.msg_edit_row(self.msg_error(_("Error: {}").format(b))) _logger.error(f"error phase2: {b}") def _executed_inheritance_status(self): @@ -2203,9 +2218,9 @@ class BalBuildWillDialog(BalDialog): } if reason in messages: - return messages[reason] + "\n\n" + _("Skipped") + return "{}\n\n{}".format(messages[reason], _("Skipped")) - return ( + return "{}\n\n{}".format( _( "Could not build the will, and the exact cause could not be " "determined. Please check that:\n" @@ -2213,12 +2228,14 @@ class BalBuildWillDialog(BalDialog): "- each heir's share is above the minimum (dust limit),\n" "- the Check Alive date is EARLIER than the delivery date,\n" "- at least one will-executor is selected and reachable." - ) - + "\n\n" - + _("Skipped") + ), + _("Skipped"), ) - def msg_set_checking(self, status="Waiting", row=None): + def msg_set_checking(self, status=None, row=None): + # The default is resolved here, not in the signature: a default + # argument is evaluated at import time, before the catalog is loaded. + status = _("Waiting") if status is None else status row = self.check_row if row is None else row self.check_row = self.msg_set_status(_("Checking your will"), row, status) @@ -2231,30 +2248,36 @@ class BalBuildWillDialog(BalDialog): def msg_set_building(self, status=None, row=None,color=None): row = self.build_row if row is None else row self.build_row = self.msg_set_status( - "Building your will", self.build_row, status, color + _("Building your will"), self.build_row, status, color ) def msg_set_signing(self, status=None, row=None): row = self.sign_row if row is None else row - self.sign_row = self.msg_set_status("Signing your will", self.sign_row, status) + self.sign_row = self.msg_set_status( + _("Signing your will"), self.sign_row, status + ) def msg_set_pushing(self, status=None, row=None): row = self.push_row if row is None else row self.push_row = self.msg_set_status( - "Broadcasting your will to executors", self.push_row, status + _("Broadcasting your will to executors"), self.push_row, status ) def msg_set_waiting(self, status=None, row=None): row = self.wait_row if row is None else row - self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) + self.wait_row = self.msg_edit_row( + _("Please wait {}secs").format(status), self.wait_row + ) def msg_error(self, e): # Results are shown in bold so the outcome stands out from the # left-side state label (which stays in normal weight). return "{}".format(self.COLOR_ERROR, e) - def msg_ok(self, e="Ok"): - # Results are shown in bold (see msg_error). + def msg_ok(self, e=None): + # Results are shown in bold (see msg_error). The default "Ok" is + # translated here, not in the signature (evaluated at import time). + e = _("Ok") if e is None else e return "{}".format(self.COLOR_OK, e) def msg_warning(self, e): @@ -2283,12 +2306,14 @@ class BalBuildWillDialog(BalDialog): # glance. ``status`` may already contain rich-text emitted by # msg_ok/msg_error/msg_warning (which add their own ...); wrapping # it again in is harmless for those cases. - status = "Wait" if status is None else status + # ``msg`` must already be translated by the caller: a variable + # passed to _() cannot be extracted into the catalog. + status = _("Wait") if status is None else status if color is None: - line = "{}:\t{}".format(_(msg), status) + line = "{}:\t{}".format(msg, status) else: line = "{}:\t{}".format( - _(msg), color, status + msg, color, status ) return self.msg_edit_row(line, row) @@ -2416,15 +2441,15 @@ class WillDetailDialog(BalDialog): self.paint_scroll_area() self.vlayout.addWidget( - QLabel(_("Expiration date: ") + str(BalTimestamp(self.threshold))) + QLabel(_("Expiration date: {}").format(BalTimestamp(self.threshold))) ) self.vlayout.addWidget(self.scrollbox) w = QWidget() hlayout = QHBoxLayout(w) hlayout.addWidget( - QLabel(_("Valid Txs:") + str(len(Will.only_valid_list(self.will)))) + QLabel(_("Valid Txs:{}").format(len(Will.only_valid_list(self.will)))) ) - hlayout.addWidget(QLabel(_("Total Txs:") + str(len(self.will)))) + hlayout.addWidget(QLabel(_("Total Txs:{}").format(len(self.will)))) self.vlayout.addWidget(w) self.setLayout(self.vlayout) @@ -2477,18 +2502,19 @@ class WillDetailDialog(BalDialog): def toggle_replaced(self): self.bal_window.bal_plugin.hide_replaced() - toggle = _("Hide") + # Whole sentences, so a translation can change the word order. + text = _("Hide replaced") if self.bal_window.bal_plugin._hide_replaced: - toggle = _("Unhide") - self.toggle_replace_button.setText(f"{toggle} {_('replaced')}") + text = _("Unhide replaced") + self.toggle_replace_button.setText(text) self.update() def toggle_invalidated(self): self.bal_window.bal_plugin.hide_invalidated() - toggle = _("Hide") + text = _("Hide invalidated") if self.bal_window.bal_plugin._hide_invalidated: - toggle = _("Unhide") - self.toggle_invalidate_button.setText(_(f"{toggle} {_('invalidated')}")) + text = _("Unhide invalidated") + self.toggle_invalidate_button.setText(text) self.update() def update(self): @@ -2768,7 +2794,7 @@ class BalQrExportWidget(QWidget): size_row = QHBoxLayout() size_row.addWidget(QLabel(_("QR code size:"))) self.size_combo = QComboBox() - self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + self.size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS]) self.size_combo.setCurrentIndex( preset_index_for_chunk_size(self.chunk_size) ) @@ -2783,7 +2809,8 @@ class BalQrExportWidget(QWidget): self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS) self.format_combo.addItems( [ - format_name(fmt) + ((" (default)") if fmt == "balqr" else "") + format_name(fmt) + + (" {}".format(_("(default)")) if fmt == "balqr" else "") for fmt in self._format_options ] ) diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index bb7ece3..9f15edb 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -20,6 +20,7 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate from .common import ( + N_, OP_RETURN_PREFIX, BalTimestamp, Buttons, @@ -59,6 +60,7 @@ from .common import ( datetime, enum, export_meta_gui, + format_status_history, getOpenFileName, import_meta_gui, is_op_return_address, @@ -69,6 +71,7 @@ from .common import ( server_status_tooltip, signature_suffix, status_color, + translated_headers, tx_from_any, write_json_file, ) @@ -128,9 +131,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin): AMOUNT = enum.auto() headers = { - Columns.NAME: _("Name"), - Columns.ADDRESS: _("Address"), - Columns.AMOUNT: _("Amount"), + Columns.NAME: N_("Name"), + Columns.ADDRESS: N_("Address"), + Columns.AMOUNT: N_("Amount"), } filter_columns = [Columns.NAME, Columns.ADDRESS] @@ -245,7 +248,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin): col=self.Columns.NAME, role=self.ROLE_HEIR_KEY ) self.model().clear() - self.update_headers(self.__class__.headers) + self.update_headers(translated_headers(self.__class__.headers)) set_current = None for key in sorted(self.bal_window.heirs.keys()): heir = self.bal_window.heirs[key] @@ -337,11 +340,11 @@ class PreviewList(MyTreeView, MessageBoxMixin): SERVER = enum.auto() headers = { - Columns.LOCKTIME: _("Locktime"), - Columns.TXID: _("Txid"), - Columns.WILLEXECUTOR: _("Will-Executor"), - Columns.STATUS: _("Status"), - Columns.SERVER: _("Server"), + Columns.LOCKTIME: N_("Locktime"), + Columns.TXID: N_("Txid"), + Columns.WILLEXECUTOR: N_("Will-Executor"), + Columns.STATUS: N_("Status"), + Columns.SERVER: N_("Server"), } ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000 @@ -586,7 +589,7 @@ class PreviewList(MyTreeView, MessageBoxMixin): if bal_tx.we: we = bal_tx.we["url"] labels[self.Columns.WILLEXECUTOR] = we - status = bal_tx.status + signature_suffix(bal_tx) + status = format_status_history(bal_tx.status) + signature_suffix(bal_tx) if len(status) > 53: status = "...{}".format(status[-50:]) labels[self.Columns.STATUS] = status @@ -644,7 +647,7 @@ class PreviewList(MyTreeView, MessageBoxMixin): col=self.Columns.TXID, role=self.ROLE_HEIR_KEY ) self.model().clear() - self.update_headers(self.__class__.headers) + self.update_headers(translated_headers(self.__class__.headers)) set_current = None for txid, bal_tx in self.will.items(): @@ -676,7 +679,7 @@ class PreviewList(MyTreeView, MessageBoxMixin): # The Wizard is the main entry point to create an inheritance, so make # it stand out: show a bold label next to a slightly larger icon (the # plain icon-only button was too easy to overlook). - wizard = QPushButton(" " + _("Build Your Will")) + wizard = QPushButton(" {}".format(_("Build Your Will"))) wizard.setIcon( read_QIcon_from_bytes( self.bal_window.bal_plugin.read_file("icons/wizard.png") @@ -904,12 +907,12 @@ class WillExecutorListWidget(MyTreeView): ADDRESS = enum.auto() headers = { - Columns.SELECTED: _(""), - Columns.URL: _("Url"), - Columns.STATUS: _("S"), - Columns.BASE_FEE: _("Base fee"), - Columns.INFO: _("Info"), - Columns.ADDRESS: _("Default Address"), + Columns.SELECTED: "", + Columns.URL: N_("Url"), + Columns.STATUS: N_("S"), + Columns.BASE_FEE: N_("Base fee"), + Columns.INFO: N_("Info"), + Columns.ADDRESS: N_("Default Address"), } filter_columns = [Columns.URL] @@ -1091,7 +1094,7 @@ class WillExecutorListWidget(MyTreeView): col=self.Columns.URL, role=self.ROLE_HEIR_KEY ) self.model().clear() - self.update_headers(self.__class__.headers) + self.update_headers(translated_headers(self.__class__.headers)) set_current = None @@ -1236,13 +1239,14 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): buttonbox.addWidget(b) def _menu_button(label): + # ``label`` must already be translated by the caller. btn = QToolButton() - btn.setText(_(label)) + btn.setText(label) btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) buttonbox.addWidget(btn) return btn - export_btn = _menu_button("Export") + export_btn = _menu_button(_("Export")) export_menu = QMenu(export_btn) export_menu.addAction(_("Export all"), lambda: self.export_file()) export_menu.addAction( @@ -1253,7 +1257,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): ) export_btn.setMenu(export_menu) - ping_btn = _menu_button("Ping All") + ping_btn = _menu_button(_("Ping All")) ping_menu = QMenu(ping_btn) ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors()) ping_menu.addAction( @@ -1261,7 +1265,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): ) ping_btn.setMenu(ping_menu) - select_btn = _menu_button("Select All") + select_btn = _menu_button(_("Select All")) select_menu = QMenu(select_btn) select_menu.addAction(_("Select all"), lambda: self.set_select_all(True)) select_menu.addAction( @@ -1295,7 +1299,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): url_edit = QLineEdit() url_edit.setFixedWidth(32 * char_width_in_lineedit()) - info_edit = QLineEdit("New Will Executor") + info_edit = QLineEdit(_("New Will Executor")) info_edit.setFixedWidth(32 * char_width_in_lineedit()) base_fee_spin = QSpinBox() base_fee_spin.setRange(0, 1000000) @@ -1309,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): if executor: url_edit.setText(edit_key) - info_edit.setText(str(executor.get("info", "New Will Executor"))) + info_edit.setText(str(executor.get("info", _("New Will Executor")))) base_fee_spin.setValue(int(executor.get("base_fee", 0))) address_edit.setText(str(executor.get("address", ""))) @@ -1469,7 +1473,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): break self._add_another = False url_edit.clear() - info_edit.setText("New Will Executor") + info_edit.setText(_("New Will Executor")) base_fee_spin.setValue(0) address_edit.clear() diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 6c16ada..da234f2 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -20,6 +20,7 @@ from electrum.util import EventListener, event_listener from PyQt6.QtWidgets import QLayout from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size +from ...i18n import init_from_config from .common import ( BalPlugin, Buttons, @@ -67,6 +68,11 @@ class Plugin(BalPlugin, EventListener): def __init__(self, parent, config, name): _logger.info("INIT BALPLUGIN") BalPlugin.__init__(self, parent, config, name) + # Load BAL's catalog for the language Electrum's GUI is using, before + # any window or dialog builds its texts. It must come after + # BalPlugin.__init__: read_file() needs the plugin's parent and name. + # The CLI plugin does not call this, so the CLI stays in English. + init_from_config(self, config) self.bal_windows = {} # Status-bar buttons, keyed by id(sb.window()). Tracking them lets us # remove a stale button before creating a fresh one when a wallet is @@ -298,7 +304,7 @@ class Plugin(BalPlugin, EventListener): pass b = StatusBarButton( read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")), - "Bal " + _("Bitcoin After Life"), + "Bal {}".format(_("Bitcoin After Life")), lambda: self.settings_dialog(sb.window()), sb.height(), ) @@ -442,7 +448,7 @@ class Plugin(BalPlugin, EventListener): def settings_dialog(self, window=None, wallet=None): - d = BalDialog(window, self, self.get_window_title("Settings")) + d = BalDialog(window, self, self.get_window_title(_("Settings"))) d.setMinimumSize(100, 200) qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png")) lbl_logo = QLabel() @@ -537,7 +543,8 @@ class Plugin(BalPlugin, EventListener): # Ordered low -> high so the user picks the resolution matching their # camera. Visible to all users (BASIC and ADVANCED). qr_size_combo = QComboBox() - qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + # Labels marked for translation in common.QR_PRESET_LABELS. + qr_size_combo.addItems([_(label) for label, _budget in CHUNK_PRESETS]) qr_size_combo.setCurrentIndex( preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get())) ) @@ -656,9 +663,9 @@ class Plugin(BalPlugin, EventListener): elif kind == "spin": widget.setValue(int(cfg.default)) elif kind == "line": - widget.setText(cfg.default) + widget.setText(cfg.localized_default()) elif kind == "text": - widget.setPlainText(cfg.default) + widget.setPlainText(cfg.localized_default()) elif kind == "user_type": widget.setCurrentIndex( 1 if str(cfg.default).lower() == "advanced" else 0 @@ -670,7 +677,7 @@ class Plugin(BalPlugin, EventListener): btn.clicked.connect(reset) return btn - heir_repush = QPushButton("Rebroadcast transactions") + heir_repush = QPushButton(_("Rebroadcast transactions")) heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) bal_mode = QComboBox() options = ["Easy", "Advanced", "Experimental"] @@ -696,27 +703,27 @@ class Plugin(BalPlugin, EventListener): # advanced-only settings, so the user picks basic/advanced first. add_widget( grid, - "Hide Replaced", + _("Hide Replaced"), heir_hide_replaced, 0, - "Hide replaced transactions from will detail and list", + _("Hide replaced transactions from will detail and list"), ) grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3) add_widget( grid, - "Hide Invalidated", + _("Hide Invalidated"), heir_hide_invalidated, 1, - "Hide invalidated transactions from will detail and list", + _("Hide invalidated transactions from will detail and list"), ) grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3) lbl_auto_sign = QLabel(_("Auto-sign on Check")) - help_auto_sign = HelpButton( + help_auto_sign = HelpButton(_( "When checking, automatically sign and broadcast the will " "transactions to their will-executors.\n" "The wallet password is requested only if the wallet is " "encrypted." - ) + )) grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0) grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1) grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2) @@ -726,10 +733,10 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3) add_widget( grid, - "Panel editable Date and Fee", + _("Panel editable Date and Fee"), heir_editable_dates, 3, - ( + _( "When enabled, the delivery-time and check-alive date fields " "can be edited everywhere (toolbar / Heirs tab), not only in " "the will-building wizard.\n" @@ -741,10 +748,10 @@ class Plugin(BalPlugin, EventListener): # will-executor. Visible to all users (BASIC and ADVANCED). add_widget( grid, - "Max Will-Executor Fee (satoshi)", + _("Max Will-Executor Fee (satoshi)"), heir_max_willexecutor_fee, 4, - ( + _( "Maximum fee (in satoshi) allowed to be paid to a single " "will-executor. If a will-executor charges more than this, " "the will will not be built.\n" @@ -756,10 +763,10 @@ class Plugin(BalPlugin, EventListener): # user chooses basic/advanced first, then sees the relevant options. add_widget( grid, - "User Type", + _("User Type"), user_type_combo, 5, - ( + _( "Choose how much detail the plugin shows.\n\n" "BASIC: simplified interface, safe configuration for most " "users.\n\n" @@ -774,10 +781,10 @@ class Plugin(BalPlugin, EventListener): # only in ADVANCED mode. In BASIC mode the factory defaults are always # used and these settings are hidden. lbl_num_reminders = QLabel(_("Number of reminders")) - help_num_reminders = HelpButton( + help_num_reminders = HelpButton(_( "How many reminder alarms the exported calendar (.ics) event " "contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0) grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1) grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2) @@ -785,13 +792,13 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3) lbl_event_summary = QLabel(_("Event summary")) - help_event_summary = HelpButton( + help_event_summary = HelpButton(_( "Default message to be used in event summary\n" "Variables:\n" " $wallet_name: name of wallet\n" " $heirs_complete: list of heirs name,address,amount\n" "Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0) grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1) grid.addWidget(_hide_if_basic(help_event_summary), 7, 2) @@ -799,13 +806,13 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3) lbl_event_description = QLabel(_("Event description")) - help_event_description = HelpButton( + help_event_description = HelpButton(_( "Default message to be used in event description\n" "Variables:\n" " $wallet_name: name of wallet\n" " $heirs_complete: list of heirs name,address,amount\n" "Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0) grid.addWidget(_hide_if_basic(edit_event_description), 8, 1) grid.addWidget(_hide_if_basic(help_event_description), 8, 2) @@ -814,10 +821,10 @@ class Plugin(BalPlugin, EventListener): # Welist server URL: shown only in ADVANCED mode. In BASIC mode the # factory default is always used and the setting is hidden. lbl_welist_server = QLabel(_("Welist Server URL")) - help_welist_server = HelpButton( + help_welist_server = HelpButton(_( "URL of the server that provides the will-executor list. " "Only available in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0) grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1) grid.addWidget(_hide_if_basic(help_welist_server), 9, 2) @@ -825,11 +832,11 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3) lbl_calendar_app = QLabel(_("Calendar app command")) - help_calendar_app = HelpButton( + help_calendar_app = HelpButton(_( "Command used to open .ics calendar files.\n" "Leave empty to use the system default (xdg-open/open/start).\n" "Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0) grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1) grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2) @@ -840,13 +847,13 @@ class Plugin(BalPlugin, EventListener): # label field is disabled while the checkbox is off (see # on_save_history_change above). lbl_save_history = QLabel(_("Save inheritance transactions in history")) - help_save_history = HelpButton( + help_save_history = HelpButton(_( "After each check, save the valid will transactions into the " "wallet's local history (the History tab), each with a label.\n" "The label may contain the variable:\n" " {willexecutor}: replaced with the will-executor URL of the item\n" "Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0) grid.addWidget(_hide_if_basic(heir_save_history), 11, 1) grid.addWidget(_hide_if_basic(help_save_history), 11, 2) @@ -854,13 +861,13 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3) lbl_history_label = QLabel(_("History label")) - help_history_label = HelpButton( + help_history_label = HelpButton(_( "Label applied to the will transactions saved into the wallet's " "local history.\n" "Variables:\n" " {willexecutor}: replaced with the will-executor URL of the item\n" "Only used in ADVANCED mode." - ) + )) grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0) grid.addWidget(_hide_if_basic(edit_history_label), 12, 1) grid.addWidget(_hide_if_basic(help_history_label), 12, 2) @@ -877,7 +884,7 @@ class Plugin(BalPlugin, EventListener): grid.addWidget(heir_repush, 13, 0) grid.addWidget( HelpButton( - "Broadcast all transactions to willexecutors including those already pushed" + _("Broadcast all transactions to willexecutors including those already pushed") ), 13, 2, @@ -887,12 +894,12 @@ class Plugin(BalPlugin, EventListener): # Placed below the rebroadcast button so the existing rows keep their # numbers. lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close")) - help_rebuild_on_close = HelpButton( + help_rebuild_on_close = HelpButton(_( "Run the 'Build your will' wizard every time the wallet is closed " "or Electrum is quit, so the will is rebuilt and re-validated.\n" "When disabled, the will is only rebuilt when you press Check or " "Prepare. The last built state is still saved to the wallet." - ) + )) grid.addWidget(lbl_rebuild_on_close, 14, 0) grid.addWidget(heir_rebuild_on_close, 14, 1) grid.addWidget(help_rebuild_on_close, 14, 2) @@ -905,7 +912,7 @@ class Plugin(BalPlugin, EventListener): # BASIC + ADVANCED), right below the "Rebuild will on wallet close" # row. lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions")) - help_auto_rebuild = HelpButton( + help_auto_rebuild = HelpButton(_( "When a new transaction arrives for the wallet, automatically " "rebuild the will the same way the wizard does at wallet close: " "the delivery date is anticipated by one day so the new will " @@ -916,7 +923,7 @@ class Plugin(BalPlugin, EventListener): "threshold, or when the threshold is already in the past.\n" "When disabled (default), the will is only rebuilt on Check / " "Prepare / wallet close." - ) + )) grid.addWidget(lbl_auto_rebuild, 15, 0) grid.addWidget(heir_auto_rebuild, 15, 1) grid.addWidget(help_auto_rebuild, 15, 2) @@ -929,13 +936,13 @@ class Plugin(BalPlugin, EventListener): # size used when exporting a will via QR codes; changeable per export # inside the export dialog itself. lbl_qr_size = QLabel(_("QR Code Size")) - help_qr_size = HelpButton( + help_qr_size = HelpButton(_( "Payload size of a single QR code when exporting a will via QR.\n\n" "Larger QR codes hold more data (fewer shots) but are easier to " "scan with a high-resolution camera; smaller QR codes scan fine " "even with low-resolution cameras but require more shots.\n" "The same selector is available inside the export dialog." - ) + )) grid.addWidget(lbl_qr_size, 16, 0) grid.addWidget(qr_size_combo, 16, 1) grid.addWidget(help_qr_size, 16, 2) @@ -990,9 +997,9 @@ class Plugin(BalPlugin, EventListener): elif kind == "spin": widget.setValue(int(cfg.default)) elif kind == "line": - widget.setText(cfg.default) + widget.setText(cfg.localized_default()) elif kind == "text": - widget.setPlainText(cfg.default) + widget.setPlainText(cfg.localized_default()) elif kind == "user_type": # Default is "basic" -> combo index 0; "advanced" -> index 1. widget.setCurrentIndex( @@ -1031,7 +1038,7 @@ class Plugin(BalPlugin, EventListener): bottom_row = QHBoxLayout() bottom_row.addWidget(btn_reset) bottom_row.addStretch(1) - bottom_row.addWidget(QLabel("" + _("Support:") + "")) + bottom_row.addWidget(QLabel("{}".format(_("Support:")))) bottom_row.addWidget(lbl_support) # Outer layout: warning (top) -> settings grid -> bottom button row. @@ -1083,6 +1090,11 @@ class Plugin(BalPlugin, EventListener): ) def get_window_title(self, title): - return _("BAL - ") + _(title) + """Return the window title "BAL - ". + + ``title`` must already be translated by the caller: a variable passed + to _() cannot be extracted into the catalog. + """ + return _("BAL - {}").format(title) diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py index a455811..7ece6cb 100644 --- a/bal/gui/qt/theme.py +++ b/bal/gui/qt/theme.py @@ -87,7 +87,7 @@ def server_status_text(will_item) -> str: the user always knows whether each inheritance transaction is actually stored on the will-executor servers, regardless of the row colour. """ - from electrum.i18n import _ + from ...i18n import _ if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"): return _("Not on server") @@ -105,7 +105,7 @@ def server_status_text(will_item) -> str: def server_status_tooltip(will_item) -> str: """Return a detailed tooltip for the "Server" column, including the will-executor URL (if any) and the current server state.""" - from electrum.i18n import _ + from ...i18n import _ url = None we = getattr(will_item, "we", None) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 1c1deb7..8eccef5 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -31,6 +31,7 @@ from ...core.reminders import build_ics_reminders, write_temp_ics from .calendar import BalCalendar, BalCalendarButton from .common import ( DECIMAL_POINT, + N_, NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, Any, @@ -67,6 +68,7 @@ from .common import ( _logger, char_width_in_lineedit, datetime, + format_status_history, getSaveFileName, log_error, os, @@ -207,10 +209,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): current_index = None default_value = None - help_text = ( + help_text = N_( "if you choose Raw, you can insert various options based on suffix:\n" - + " - d: number of days after current day(ex: 1d means tomorrow)\n" - + " - y: number of years after currrent day(ex: 1y means one year from today)\n" + " - d: number of days after current day(ex: 1d means tomorrow)\n" + " - y: number of years after currrent day(ex: 1y means one year from today)\n" ) label_text = None tooltip_text = None @@ -276,7 +278,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): ] ) #hbox.addWidget(QLabel(self.label_text)) - help_button=HelpButton(self.help_text) + # help_text and tooltip_text are class attributes marked with N_(): + # translate them here, when shown. + help_button=HelpButton(_(self.help_text)) help_button.setText(self.label_text) # Show a short label (e.g. "Delivery time" / "Check Alive") when the # user hovers the icon, so the emoji button is self-explanatory. @@ -625,7 +629,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): class ThresholdTimeWidget(BalTimeEditWidget): # rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render. - help_text = ( + help_text = N_( "<b>CHECK ALIVE</b><br><br>" "In DATA mode:<br>" "set the date for the \u201ccheck alive\u201d parameter.<br>" @@ -644,7 +648,7 @@ class ThresholdTimeWidget(BalTimeEditWidget): ) label_text = "🚨" #label_text = "Check Alive" - tooltip_text = "Check Alive" + tooltip_text = N_("Check Alive") base_field = "threshold" def __init__(self, bal_window, parent, init_value=None): @@ -659,7 +663,7 @@ class ThresholdTimeWidget(BalTimeEditWidget): class LockTimeWidget(BalTimeEditWidget): # rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render. - help_text = ( + help_text = N_( "<b>DELIVERY TIME</b><br><br>" "Set Locktime for transactions.<br>" "Any time is needed transaction will be anticipated by 1day<br><br>" @@ -677,7 +681,7 @@ class LockTimeWidget(BalTimeEditWidget): #label_text = "Locktime" # Hover tooltip for the delivery-time icon; mirrors the style of the fee # icon tooltip ("..., click for more information") so the two are consistent. - tooltip_text = "Delivery Time, click for more information" + tooltip_text = N_("Delivery Time, click for more information") base_field = "locktime" def __init__(self, bal_window, parent, init_value=None): @@ -1088,9 +1092,12 @@ class WillSettingsWidget(QWidget): basic_mode = self.bal_window.bal_plugin.is_basic_mode() if basic_mode: # BASIC mode: use factory defaults (the hidden settings are - # ignored) and the fixed 30/10/1 offsets. - raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default - raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default + # ignored) and the fixed 30/10/1 offsets. The defaults are in + # the GUI language (see BalConfig). + raw_description = ( + self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default() + ) + raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default() threshold = None num_reminders = 3 else: @@ -1120,6 +1127,7 @@ class WillSettingsWidget(QWidget): version=self.bal_window.bal_plugin.version, num_reminders=num_reminders, threshold=threshold, + reminder_suffix=_("(reminder {idx}/{total})"), ) except Exception as e: _logger.error(f"failed to generate .ics: {e}") @@ -1199,7 +1207,7 @@ class PercAmountEdit(BTCAmountEdit): painter.drawText( text_rect, int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), - self.base_unit() + " or perc value", + _("{} or perc value").format(self.base_unit()), ) @@ -1317,11 +1325,12 @@ class WillWidget(QWidget): creation = str(BalTimestamp(self.will[w].time)) def qlabel(title, value): - label = "<b>" + _(str(title)) + f":</b>\t{str(value)}" - return QLabel(label) + # ``title`` is shown as it is: callers pass a translated + # label, or data (an heir name, a will-executor URL). + return QLabel("<b>{}:</b>\t{}".format(title, value)) - detaillayout.addWidget(qlabel("Locktime", locktime)) - detaillayout.addWidget(qlabel("Creation Time", creation)) + detaillayout.addWidget(qlabel(_("Locktime"), locktime)) + detaillayout.addWidget(qlabel(_("Creation Time"), creation)) try: total_fees = ( self.will[w].tx.input_value() - self.will[w].tx.output_value() @@ -1331,12 +1340,17 @@ class WillWidget(QWidget): decoded_fees = total_fees fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" - detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) + detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str)) + # The saved status history is English: translate it for display. detaillayout.addWidget( - qlabel("Status:", self.will[w].status + signature_suffix(self.will[w])) + qlabel( + _("Status"), + format_status_history(self.will[w].status) + + signature_suffix(self.will[w]), + ) ) detaillayout.addWidget(QLabel("")) - detaillayout.addWidget(QLabel("<b>Heirs:</b>")) + detaillayout.addWidget(QLabel("<b>{}</b>".format(_("Heirs:")))) for heir_name in self.will[w].heirs: if 'w!ll3x3c"' in heir_name: continue @@ -1363,7 +1377,9 @@ class WillWidget(QWidget): ) if self.will[w].we: detaillayout.addWidget(QLabel("")) - detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:"))) + detaillayout.addWidget( + QLabel("<b>{}</b>".format(_("Willexecutor:"))) + ) decoded_amount = Util.decode_amount( self.will[w].we["base_fee"], self._bal_parent.decimal_point ) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 55c3784..0ab2e91 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -26,6 +26,7 @@ from ...core.checkalive import ( resolve_guard_threshold, ) from .common import ( + N_, OP_RETURN_PREFIX, AmountException, BalPlugin, @@ -282,12 +283,12 @@ class BalWindow: def new_heir_dialog(self, heir_key=None): heir = self.heirs.get(heir_key) - title = "New heir" + title = _("New heir") if heir: - title = f"Edit: {heir_key}" + title = _("Edit: {}").format(heir_key) d = BalDialog( - self.window, self.bal_plugin, self.bal_plugin.get_window_title(_(title)) + self.window, self.bal_plugin, self.bal_plugin.get_window_title(title) ) vbox = QVBoxLayout(d) @@ -828,12 +829,12 @@ class BalWindow: except AmountException as e: self.show_warning( _( - f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" - ) + "In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{}" + ).format(e) ) except WillExecutorFeeTooHighException as e: self.show_error( - _(f"Will-executor fee too high: {e}") + _("Will-executor fee too high: {}").format(e) ) return except CheckAliveError: @@ -919,27 +920,27 @@ class BalWindow: _logger.info("{}:{}".format(type(e), e)) message = False if isinstance(e, HeirChangeException): - message = "Heirs changed:" + message = _("Heirs changed:") elif isinstance(e, WillExecutorNotPresent): - message = "Will-Executor not present:" + message = _("Will-Executor not present:") elif isinstance(e, WillexecutorChangeException): - message = "Will-Executor changed" + message = _("Will-Executor changed") elif isinstance(e, TxFeesChangedException): - message = "Txfees are changed" + message = _("Txfees are changed") elif isinstance(e, HeirNotFoundException): # Task #01b: replace the misleading "Heir not found" text. # This branch is most often hit because the delivery date # was anticipated, not because an heir is missing, so we use # a clear message that covers both the DATE and the HEIRS # cases (kept consistent with dialogs.py / the CHECK window). - message = ( + message = _( "Found CHANGES to the DATE or the HEIRS,\n" "a NEW WILL must be prepared." ) if message: self.show_message( - f"{_(message)}:\n {e}\n{_('will have to be built')}" + "{}:\n {}\n{}".format(message, e, _("will have to be built")) ) _logger.info("build will") @@ -959,7 +960,7 @@ class BalWindow: self.invalidate_will() except NotCompleteWillException as e: self.show_error( - "Error:{}\n {}".format( + _("Error:{}\n {}").format( str(e), _("Please, check your heirs, locktime and threshold!"), ) @@ -1019,7 +1020,9 @@ class BalWindow: except SerializationError as e: _logger.error("unable to deserialize the transaction") parent.show_critical( - _("Electrum was unable to deserialize the transaction:") + "\n" + str(e) + "{}\n{}".format( + _("Electrum was unable to deserialize the transaction:"), e + ) ) else: # Electrum's own TxDialog: keep it in front of the main window. @@ -1087,8 +1090,8 @@ class BalWindow: def get_message(): msg = "" if signed: - msg = _(f"signed: {signed}\n") - return msg + _(f"signing: {tosign}") + msg = _("signed: {}").format(signed) + "\n" + return msg + _("signing: {}").format(tosign) if txids is not None: targets = [ @@ -1610,7 +1613,7 @@ class BalWindow: willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force) def getMsg(willexecutors): - msg = "Broadcasting Transactions to Will-Executors:\n" + msg = _("Broadcasting Transactions to Will-Executors:") + "\n" for url in willexecutors: msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n" return msg @@ -1664,8 +1667,8 @@ class BalWindow: if self.waiting_dialog._stopping: return self.waiting_dialog.update( - "checking {} - {} : {}".format( - willitems[wid].we["url"], wid, "Waiting" + _("checking {} - {} : {}").format( + willitems[wid].we["url"], wid, _("Waiting") ) ) w = willitems[wid] @@ -1673,7 +1676,7 @@ class BalWindow: Willexecutors.check_transaction(wid, w.we["url"]) ) self.waiting_dialog.update( - "checked {} - {} : {}".format( + _("checked {} - {} : {}").format( willitems[wid].we["url"], wid, willitems[wid].get_status("CHECKED"), @@ -2142,7 +2145,7 @@ class BalWindow: # Simple, user-facing message shown when the download fails for any reason # (the technical cause is in the Electrum log). - DOWNLOAD_FAILED_MESSAGE = ( + DOWNLOAD_FAILED_MESSAGE = N_( "Could not download the will-executors list.\n\n" "This is usually caused by your internet connection or a firewall, " "not by the plugin. Please check your connection (a VPN often helps) " @@ -2152,7 +2155,7 @@ class BalWindow: # Shown when the download fails while Electrum is connected through Tor: # the most common cause is a slow Tor connection, so guide the user # accordingly instead of the generic message above. - DOWNLOAD_FAILED_TOR_MESSAGE = ( + DOWNLOAD_FAILED_TOR_MESSAGE = N_( "Could not download the will-executors list over Tor.\n\n" "Electrum is connected through Tor and the connection is taking too " "long. Your Tor connection may be slow. Please try again, or use a VPN " @@ -2225,27 +2228,27 @@ class BalWindow: if err.url and err.reason == "empty response": # Server reached but returned no data for this chain. self.show_warning(_( - f"No active will-executor servers found for the " - f"{err.chain} network.\n\n" - f"The welist server at {err.url} responded but " - f"returned no will-executors for this chain." - )) + "No active will-executor servers found for the " + "{chain} network.\n\n" + "The welist server at {url} responded but " + "returned no will-executors for this chain." + ).format(chain=err.chain, url=err.url)) elif err.url: # Advanced mode with a non-empty error (network issue). self.show_warning(_( - f"Could not reach the configured welist server.\n\n" - f"Server: {err.url}\n" - f"Error: {err.reason}\n\n" - f"Please verify the welist server URL in the plugin " - f"settings." - )) + "Could not reach the configured welist server.\n\n" + "Server: {url}\n" + "Error: {reason}\n\n" + "Please verify the welist server URL in the plugin " + "settings." + ).format(url=err.url, reason=err.reason)) else: # Basic mode: the server responded but has no data for this # chain. self.show_warning(_( - f"No active will-executor found for the " - f"{err.chain} network." - )) + "No active will-executor found for the " + "{chain} network." + ).format(chain=err.chain)) else: # Tor-aware: a generic failure/timeout while on Tor is most # likely a slow Tor connection. diff --git a/bal/i18n.py b/bal/i18n.py new file mode 100644 index 0000000..5a734f7 --- /dev/null +++ b/bal/i18n.py @@ -0,0 +1,198 @@ +""" +bal.i18n +======== + +Translation layer of the BAL plugin (gettext domain ``bal``). + +Why BAL has its own catalog +--------------------------- +Electrum translates its interface with gettext (domain ``electrum``). Its +translators only see Electrum's own source code, so the texts of an external +(zip) plugin such as BAL are not in Electrum's catalog, and Electrum offers no +way to translate external plugins. BAL therefore ships its own compiled +catalogs inside the plugin: ``locale/<lang>/LC_MESSAGES/bal.mo``. + +Lookup order +------------ +:func:`_` returns the first translation found in: + +1. **Electrum's catalog.** When Electrum already translates the exact same + English text, its translation wins. BAL then reads like Electrum and + follows Electrum's own translation fixes; if Electrum ever ships BAL as an + internal plugin, Electrum's translations take over with no change here + (owner's decision D7 in ``PLAN_I18N.md``). +2. **BAL's catalog**, for the texts that only BAL has. +3. **The English source text.** + +Language +-------- +Electrum picks the GUI language once, at start-up (``run_electrum``), and a +change needs a restart. :func:`init_from_config` repeats Electrum's choice +once, when the Qt plugin is created. BAL never detects the language by +itself: otherwise part of a window could be in one language and part in +another. The CLI never calls it, so the CLI stays in English, like Electrum's. + +Rules for translatable texts +---------------------------- +* Wrap literal English text only: ``_("Sign the will")``. +* Put the variable parts in ``{}`` and fill them *after* translating: + ``_("{} heirs").format(count)``. Never build the text with an f-string or + ``%`` inside ``_()``: it would change at run time and never match a catalog + entry (Ruff rules INT001-INT003 catch this). +* Texts evaluated at import time (class attributes, tables, constants) are + marked with :func:`N_` and passed to ``_()`` when they are displayed. Every + class body runs before :func:`init_from_config`, so a ``_()`` there would + always return English. +* Never translate text that is stored or compared (wallet labels, the saved + status history, config values used to recognise BAL transactions): stored + data must not depend on the UI language. +""" + +import gettext +import io +import string +from typing import Optional + +from electrum.i18n import _ as _electrum_gettext +from electrum.logging import get_logger + +_logger = get_logger(__name__) + +#: gettext domain of the BAL catalogs. +DOMAIN = "bal" + +# BAL catalog of the current language, or None (English, CLI, or no usable +# catalog). Set by set_language(). +_catalog: Optional[gettext.GNUTranslations] = None + +# Source texts whose BAL translation was rejected: the warning is logged once, +# not at every repaint of the widget that shows the text. +_rejected: set = set() + +_formatter = string.Formatter() + + +def keeps_format_fields(msg: str, translation: str) -> bool: + """Tell whether ``translation`` keeps the ``{}`` fields of ``msg``. + + A translation that adds, drops or renames a replacement field would make + the ``.format()`` call that follows raise, or put a value in the wrong + place, so such a translation must not be used. + + The rules are copied from Electrum's + ``_ensure_translation_keeps_format_string_syntax_similar`` + (``electrum/i18n.py``, MIT licence, Copyright (C) The Electrum developers) + rather than importing that private function, which may change without + notice. Keep them identical, so a BAL translation is accepted or rejected + exactly like an Electrum one. The only addition: a malformed source text + returns False here instead of raising. + """ + try: + # Tuples of (literal_text, field_name, format_spec, conversion). + parsed1 = list(_formatter.parse(msg)) + parsed2 = list(_formatter.parse(translation)) + except ValueError: # malformed format string + return False + if len(parsed1) != len(parsed2): + return False + # The set of field names must not change (re-ordering them is allowed). + return {t[1] for t in parsed1} == {t[1] for t in parsed2} + + +def _(msg: str) -> str: + """Return ``msg`` translated into the GUI language. + + See the module docstring for the lookup order. ``_("")`` returns ``""``: + for gettext the empty text is the key of the catalog header. + """ + if msg == "": + return "" + translation = _electrum_gettext(msg) + if translation != msg or _catalog is None: + # Electrum's translation wins (Electrum has already checked its {} + # fields); without a BAL catalog there is nothing else to look up. + return translation + translation = _catalog.gettext(msg) + if translation == msg or keeps_format_fields(msg, translation): + return translation + if msg not in _rejected: + _rejected.add(msg) + _logger.warning( + f"rejected BAL translation, replacement fields differ: " + f"{msg!r} -> {translation!r}" + ) + return msg + + +def N_(msg: str) -> str: # noqa: N802 (the conventional gettext marker name) + """Mark ``msg`` as translatable and return it unchanged. + + Used where a text is defined at import time (class attributes, tables, + constants). Extraction tools (Babel, xgettext) collect ``N_("...")`` like + ``_("...")``; the code passes the value to ``_()`` when it shows it. + """ + return msg + + +def set_language(plugin, lang: Optional[str]) -> None: + """Load the BAL catalog of ``lang`` (for example ``"it_IT"``), or none. + + ``None``, ``""`` and English (``"en_*"``) load no catalog, so the English + source texts are shown, as Electrum does for ``en_*``. + + The catalog is read with ``plugin.read_file()``, the Electrum API BAL uses + for its icons: it works both from the installed zip and from a development + checkout. ``gettext.translation(localedir=...)`` is not used because, + pointed inside a zip, it silently finds nothing. + + If ``locale/<lang>/`` has no catalog, the plain language code is tried + (``locale/it/`` for ``it_IT``). A missing or unreadable catalog is logged + and BAL stays in English: a translation problem must never stop the + plugin. + """ + global _catalog + _catalog = None + _rejected.clear() + if not isinstance(lang, str) or not lang or lang.startswith("en_"): + return + codes = [lang] + if "_" in lang: + codes.append(lang.split("_", 1)[0]) + for code in codes: + path = "locale/{}/LC_MESSAGES/{}.mo".format(code, DOMAIN) + try: + data = plugin.read_file(path) + except Exception: + # Not shipped for this code (a zip raises KeyError, a development + # folder raises OSError): try the next code. + continue + try: + _catalog = gettext.GNUTranslations(io.BytesIO(data)) + except Exception as e: + _logger.info(f"unreadable BAL catalog {path}, using English: {e!r}") + return + _logger.info(f"BAL catalog loaded: {path}") + return + _logger.info(f"no BAL catalog for {lang!r}, using English") + + +def init_from_config(plugin, config) -> None: + """Load the BAL catalog of the language Electrum's Qt GUI is using. + + Repeats Electrum's own choice in ``run_electrum``: the ``language`` + setting (Preferences > Language) or, when it is empty, the system language + if Electrum supports it, else English. Called once, from the Qt plugin's + ``__init__``. Never raises. + """ + try: + lang = config.LOCALIZATION_LANGUAGE + if not lang: + from electrum.gui.default_lang import get_default_language + + lang = get_default_language(gui_name="qt") + except Exception as e: + # Electrum also falls back to English when it cannot read the + # system language. + _logger.info(f"could not read the GUI language, using English: {e!r}") + lang = None + set_language(plugin, lang) diff --git a/pyproject.toml b/pyproject.toml index 97a6aae..879f935 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ line-length = 88 target-version = "py312" [tool.ruff.lint] -select =["E", "W", "F", "I", "N", "B"] +select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py) ignore = ["E501"] [tool.ruff.lint.pep8-naming] diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..e3cd9f5 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,309 @@ +"""Tests for bal/i18n.py, BAL's translation layer (PLAN_I18N.md, phase 1). + +The BAL catalogs are built in memory (see ``_mo_bytes``), so these tests need +no compiled .mo file and no Babel. Electrum's translator is replaced by a +small dictionary, so the tests do not depend on Electrum's own catalogs. + +Run standalone (``python3 tests/test_i18n.py``) or with pytest. +""" + +import array +import os +import struct +import sys +from contextlib import contextmanager +from types import SimpleNamespace + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal import i18n # noqa: E402 + +IT_MO = "locale/it_IT/LC_MESSAGES/bal.mo" + + +def _mo_bytes(messages): + """Return a GNU .mo catalog (UTF-8) holding ``messages`` {msgid: msgstr}. + + Same layout as CPython's Tools/i18n/msgfmt.py: header, the sorted msgid + and msgstr index tables, then the NUL-terminated strings. + """ + entries = {"": "Content-Type: text/plain; charset=UTF-8\n"} + entries.update(messages) + keys = sorted(entries) + ids = strs = b"" + offsets = [] + for key in keys: + msgid, msgstr = key.encode("utf-8"), entries[key].encode("utf-8") + offsets.append((len(ids), len(msgid), len(strs), len(msgstr))) + ids += msgid + b"\0" + strs += msgstr + b"\0" + keystart = 7 * 4 + 16 * len(keys) + valuestart = keystart + len(ids) + koffsets, voffsets = [], [] + for o1, l1, o2, l2 in offsets: + koffsets += [l1, o1 + keystart] + voffsets += [l2, o2 + valuestart] + header = struct.pack( + "Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0 + ) + return header + array.array("i", koffsets + voffsets).tobytes() + ids + strs + + +class FakePlugin: + """Stands in for Electrum's BasePlugin: serves read_file() from a dict.""" + + def __init__(self, files=None): + self.files = files or {} + self.requested = [] + + def read_file(self, filename): + self.requested.append(filename) + if filename not in self.files: + raise KeyError(filename) # what a missing entry in the zip raises + return self.files[filename] + + +@contextmanager +def _i18n_state(electrum=None, files=None, lang=None): + """Fake Electrum's translations, load a fake BAL catalog, restore after. + + Restoring matters: bal.i18n keeps module-level state, shared with every + other test of the same pytest run. + """ + saved = (i18n._electrum_gettext, i18n._catalog) + table = electrum or {} + i18n._electrum_gettext = lambda msg: table.get(msg, msg) + plugin = FakePlugin(files) + try: + i18n.set_language(plugin, lang) + yield plugin + finally: + i18n._electrum_gettext, i18n._catalog = saved + i18n._rejected.clear() + + +def test_empty_string_is_never_translated(): + # For gettext "" is the key of the catalog header. + with _i18n_state(files={IT_MO: _mo_bytes({})}, lang="it_IT"): + assert i18n._catalog is not None + assert i18n._("") == "" + + +def test_electrum_translation_wins_over_bal_catalog(): + catalog = _mo_bytes({"Cancel": "Cancella", "Heirs": "Eredi"}) + with _i18n_state( + electrum={"Cancel": "Annulla"}, files={IT_MO: catalog}, lang="it_IT" + ): + assert i18n._("Cancel") == "Annulla" # Electrum has it: Electrum wins + assert i18n._("Heirs") == "Eredi" # only BAL has it + + +def test_english_source_when_nobody_translates(): + with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"): + assert i18n._("Not in any catalog") == "Not in any catalog" + + +def test_translation_with_different_fields_is_rejected(): + catalog = _mo_bytes( + { + "{} heirs": "eredi", # field dropped: rejected + "Fee: {}": "Commissione: {}", + "{0} of {1}": "{1} di {0}", # re-ordered: allowed + "Hello {name}": "Ciao {nome}", # field renamed: rejected + } + ) + with _i18n_state(files={IT_MO: catalog}, lang="it_IT"): + assert i18n._("{} heirs") == "{} heirs" + assert i18n._("Fee: {}") == "Commissione: {}" + assert i18n._("{0} of {1}") == "{1} di {0}" + assert i18n._("Hello {name}") == "Hello {name}" + + +def test_keeps_format_fields(): + assert i18n.keeps_format_fields("Amount: {}", "Importo: {}") + assert not i18n.keeps_format_fields("Amount: {}", "Importo") + assert not i18n.keeps_format_fields("Amount: {}", "Importo: {} {}") + assert not i18n.keeps_format_fields("Amount: {}", "Importo: {") # malformed + assert i18n.keeps_format_fields("$wallet_name", "$wallet_name") + + +def test_english_loads_no_catalog(): + with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="en_UK") as p: + assert i18n._catalog is None + assert p.requested == [] + assert i18n._("Heirs") == "Heirs" + + +def test_no_language_loads_no_catalog(): + for lang in (None, ""): + with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang=lang): + assert i18n._catalog is None + + +def test_missing_catalog_falls_back_to_english(): + with _i18n_state(files={}, lang="it_IT") as plugin: + assert i18n._catalog is None + assert plugin.requested == [IT_MO, "locale/it/LC_MESSAGES/bal.mo"] + assert i18n._("Heirs") == "Heirs" + + +def test_broken_catalog_falls_back_to_english(): + with _i18n_state(files={IT_MO: b"this is not a catalog"}, lang="it_IT"): + assert i18n._catalog is None + assert i18n._("Heirs") == "Heirs" + + +def test_language_code_without_country_is_tried(): + files = {"locale/it/LC_MESSAGES/bal.mo": _mo_bytes({"Heirs": "Eredi"})} + with _i18n_state(files=files, lang="it_IT"): + assert i18n._("Heirs") == "Eredi" + + +def test_n_marks_without_translating(): + with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"): + assert i18n.N_("Heirs") == "Heirs" + assert i18n._(i18n.N_("Heirs")) == "Eredi" + + +def test_init_from_config_uses_electrum_language_setting(): + config = SimpleNamespace(LOCALIZATION_LANGUAGE="it_IT") + plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})}) + with _i18n_state(): + i18n.init_from_config(plugin, config) + assert i18n._("Heirs") == "Eredi" + + +def test_init_from_config_without_setting_uses_system_language(): + import electrum.gui.default_lang as default_lang + + saved = default_lang.get_default_language + default_lang.get_default_language = lambda gui_name=None: "it_IT" + config = SimpleNamespace(LOCALIZATION_LANGUAGE="") + plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})}) + try: + with _i18n_state(): + i18n.init_from_config(plugin, config) + assert i18n._("Heirs") == "Eredi" + finally: + default_lang.get_default_language = saved + + +def test_init_from_config_never_raises(): + plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})}) + with _i18n_state(): + i18n.init_from_config(plugin, object()) # config without the setting + assert i18n._catalog is None + + +# --- Stored data stays language-neutral (PLAN_I18N.md, phase 2) ------------- + + +def test_status_history_is_stored_in_english(): + from bal.core.will import WillItem + + catalog = _mo_bytes({"Signed": "Firmato", "Valid": "Valida"}) + with _i18n_state(files={IT_MO: catalog}, lang="it_IT"): + # A bare item: set_status() only needs its status fields. + item = WillItem.__new__(WillItem) + item.status = "" + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) + item.set_status("COMPLETE", True) + item.set_status("VALID", False) + assert item.status == ".Signed.NOT Valid" + + +def test_status_history_is_translated_for_display(): + from bal.core.will import format_status_history + + catalog = _mo_bytes( + { + "Signed": "Firmato", + "Valid": "Valida", + "NOT {}": "NON {}", + "New": "Nuovo", + "Replaced": "Sostituita", + } + ) + with _i18n_state(files={IT_MO: catalog}, lang="it_IT"): + assert format_status_history(".Signed.NOT Valid") == ".Firmato.NON Valida" + # A real history saved by an older version (tests/samanta7), which + # stored some labels already translated: known English labels are + # translated, the other tokens are kept as they are. + assert ( + format_status_history("New.Firmato.Pushed.Checked.Replaced") + == "Nuovo.Firmato.Pushed.Checked.Sostituita" + ) + assert format_status_history(".SignedERROR!!!") == ".FirmatoERROR!!!" + with _i18n_state(): # English GUI: shown exactly as stored + assert format_status_history("New.Firmato.Pushed") == "New.Firmato.Pushed" + + +class _FakeConfig(dict): + """Minimal stand-in for Electrum's SimpleConfig (get / set_key).""" + + def get(self, key, default=None): + return dict.get(self, key, default) + + def set_key(self, key, value, save=True): + self[key] = value + + +def test_translatable_config_default_follows_language(): + from bal.core.plugin_base import BalConfig + + config = _FakeConfig() + summary = BalConfig(config, "summary", "Will of $wallet_name", translatable=True) + plain = BalConfig(config, "label", "BAL label") + catalog = _mo_bytes( + {"Will of $wallet_name": "Testamento di $wallet_name", "BAL label": "X"} + ) + with _i18n_state(files={IT_MO: catalog}, lang="it_IT"): + assert summary.get() == "Testamento di $wallet_name" # nothing stored + assert summary.localized_default() == "Testamento di $wallet_name" + summary.set("Testamento di $wallet_name") # e.g. the reset button + assert config["summary"] == "Will of $wallet_name" # stored in English + assert summary.get() == "Testamento di $wallet_name" + summary.set("My own text") # the user's text is kept as it is + assert summary.get() == "My own text" + assert plain.get() == "BAL label" # not translatable: never translated + with _i18n_state(): + summary.set("Will of $wallet_name") + assert summary.get() == "Will of $wallet_name" + + +def test_calendar_reminder_suffix_is_translatable(): + from datetime import datetime, timedelta + + from bal.core.reminders import build_ics_reminders + + now = datetime(2026, 1, 1) + kwargs = dict( + locktime=now + timedelta(days=400), basic_mode=True, description="d", + summary="s", wallet_name="w", heirs_details="", version="0", now=now, + ) + assert "s (reminder 1/" in build_ics_reminders(**kwargs) # CLI default + ics = build_ics_reminders(reminder_suffix="(promemoria {idx}/{total})", **kwargs) + assert "s (promemoria 1/" in ics + + +def test_qr_preset_labels_match_chunk_presets(): + # The labels are marked for translation in common.py because + # qrtransfer.py must stay free of Electrum imports (Android copy). + from bal.core.qrtransfer import CHUNK_PRESETS + from bal.gui.qt.common import QR_PRESET_LABELS + + assert tuple(label for label, _budget in CHUNK_PRESETS) == QR_PRESET_LABELS + + +def test_real_translator_is_electrums(): + # Outside the fakes above, BAL asks Electrum's real translator first. + from electrum.i18n import _ as electrum_gettext + + assert i18n._electrum_gettext is electrum_gettext + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for test in tests: + test() + print(f"[OK] All {len(tests)} i18n tests passed")