i18n phases 1+2: BAL translation layer and translatable texts
Phase 1: new bal/i18n.py, BAL's own gettext layer (domain "bal", catalogs read with plugin.read_file()). _() asks Electrum's catalog first, then BAL's, then returns the English source. The Qt plugin loads the catalog of Electrum's GUI language at start-up; the CLI stays English. Phase 2: every user-visible GUI text is now a whole, extractable sentence (Ruff INT rules enabled). Class-level texts are marked with N_() and translated when shown. Stored data stays language-neutral: the status history is written in English and translated for display, the calendar defaults follow the GUI language, and the history label and wallet labels are never translated because BAL uses them to recognise its transactions. No visible change apart from the double colon fixed in the will detail. See CHANGELOG entries 58 and 59 and PLAN_I18N.md.
This commit is contained in:
89
CHANGELOG.md
89
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/<lang>/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
"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 {} - {} : <b>{}</b>".format(
|
||||
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
|
||||
"{} : <b>{}</b>".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 "<font color='{}'><b>{}</b></font>".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 "<font color='{}'><b>{}</b></font>".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 <b>...</b>); wrapping
|
||||
# it again in <b> 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<b>{}</b>".format(_(msg), status)
|
||||
line = "{}:\t<b>{}</b>".format(msg, status)
|
||||
else:
|
||||
line = "{}:\t<font color={}><b>{}</b></font>".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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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("<b>" + _("Support:") + "</b>"))
|
||||
bottom_row.addWidget(QLabel("<b>{}</b>".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>".
|
||||
|
||||
``title`` must already be translated by the caller: a variable passed
|
||||
to _() cannot be extracted into the catalog.
|
||||
"""
|
||||
return _("BAL - {}").format(title)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
198
bal/i18n.py
Normal file
198
bal/i18n.py
Normal file
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
309
tests/test_i18n.py
Normal file
309
tests/test_i18n.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user