i18n phase 3: Italian catalog
- babel.cfg and the catalog sources: bal/locale/bal.pot (424 texts) and
bal/locale/it_IT/LC_MESSAGES/bal.po, fully translated (43 entries taken
from Electrum's it_IT catalog, the rest following the owner's glossary
and review: "locktime" kept in English, "transazione senza
Will-Executor" for the backup transaction).
- build_zip.py compiles each bal.po into bal.mo inside the zip (Babel
required); .po/.pot are not shipped and *.mo is ignored by git.
- tests/test_translations.py: catalogs compile, {} fields and $tokens
match, no address/e-mail/URL added by a translation (patterns from
electrum-locale).
- AGENTS.md: how to update the catalogs or add a language.
See CHANGELOG entry 60 and PLAN_I18N.md.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ tmp*
|
|||||||
# Release artifacts
|
# Release artifacts
|
||||||
bal_v*.zip.*
|
bal_v*.zip.*
|
||||||
tests/karen7
|
tests/karen7
|
||||||
|
|
||||||
|
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||||
|
*.mo
|
||||||
|
|||||||
28
AGENTS.md
28
AGENTS.md
@@ -83,6 +83,32 @@ QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
|
|||||||
registration API differs between them (`json_db.register_dict` vs
|
registration API differs between them (`json_db.register_dict` vs
|
||||||
`stored_dict.register_name`).
|
`stored_dict.register_name`).
|
||||||
|
|
||||||
|
## Translations (i18n)
|
||||||
|
|
||||||
|
How it works: `bal/i18n.py` (Electrum's catalog first, then BAL's, then
|
||||||
|
English). Plan and decisions: `PLAN_I18N.md`.
|
||||||
|
|
||||||
|
- Sources in git: `babel.cfg`, `bal/locale/bal.pot` (template) and
|
||||||
|
`bal/locale/<lang>/LC_MESSAGES/bal.po` (one per language). The compiled
|
||||||
|
`.mo` files are NOT in git: `build_zip.py` builds them into the zip.
|
||||||
|
- Writing texts: `_("... {}").format(x)`, never f-strings or `%` inside
|
||||||
|
`_()` (Ruff INT); `N_()` for class attributes/constants, translated with
|
||||||
|
`_()` when shown. Never translate stored or compared text (wallet labels,
|
||||||
|
`HISTORY_LABEL`, the status history).
|
||||||
|
- After changing texts, refresh the catalogs (then translate the new
|
||||||
|
entries, e.g. with Poedit):
|
||||||
|
```bash
|
||||||
|
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||||
|
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap
|
||||||
|
```
|
||||||
|
- New language: `pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l <lang>`
|
||||||
|
(use Electrum's language code, e.g. `de_DE`).
|
||||||
|
- Dev install (symlink, not the zip): compile once to see the translations,
|
||||||
|
`pybabel compile -d bal/locale -D bal`.
|
||||||
|
- `tests/test_translations.py` checks every catalog ({} fields, `$tokens`,
|
||||||
|
no addresses/links/e-mails added by a translation) and prints a summary
|
||||||
|
when run standalone.
|
||||||
|
|
||||||
## Build / release
|
## Build / release
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -90,6 +116,8 @@ python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha25
|
|||||||
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
|
||||||
|
translation catalogs into the zip and stops if Babel is missing.
|
||||||
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
|
||||||
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
|
||||||
version there, never invent a new source of truth.
|
version there, never invent a new source of truth.
|
||||||
|
|||||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -3416,3 +3416,35 @@ depends on the GUI language.
|
|||||||
- Babel extraction (GUI + core): 424 strings (about 3,000 words), was 323.
|
- Babel extraction (GUI + core): 424 strings (about 3,000 words), was 323.
|
||||||
|
|
||||||
**Outcome:** DONE, pending the owner's test of the ZIP.
|
**Outcome:** DONE, pending the owner's test of the ZIP.
|
||||||
|
|
||||||
|
## 60. i18n phase 3: Italian catalog
|
||||||
|
|
||||||
|
**Date:** 2026-09-26 (branch `feature/i18n`, see `PLAN_I18N.md`)
|
||||||
|
|
||||||
|
**Goal (owner request):** with Electrum set to Italian, BAL shows in Italian.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `babel.cfg` (extraction mapping, CLI and wallet_util excluded),
|
||||||
|
`bal/locale/bal.pot` (template, 424 texts) and the Italian catalog
|
||||||
|
`bal/locale/it_IT/LC_MESSAGES/bal.po`: 43 entries copied from Electrum's
|
||||||
|
own it_IT catalog (same English text), the others drafted following the
|
||||||
|
owner's glossary (D1). Draft to be reviewed by the owner (D2).
|
||||||
|
- `build_zip.py` compiles every `bal.po` into `bal.mo` inside the zip
|
||||||
|
(Babel required, the build stops without it); `.po`/`.pot` are not
|
||||||
|
shipped and `*.mo` is ignored by git (D4).
|
||||||
|
- New `tests/test_translations.py`: every catalog compiles; `{}` fields and
|
||||||
|
`$tokens` match the English text; no Bitcoin address, e-mail, URL or long
|
||||||
|
letters-and-digits word that the English text does not have (patterns from
|
||||||
|
Electrum's `electrum-locale/update.py`).
|
||||||
|
- `AGENTS.md`: how to update the catalogs, add a language, compile for the
|
||||||
|
development install.
|
||||||
|
|
||||||
|
**Verification (Electrum 4.8.2, offscreen):** `tests/test_translations.py`
|
||||||
|
OK (it_IT: 424 translated, 0 untranslated, 0 fuzzy); all offline tests 640
|
||||||
|
passed, only the pre-existing failures of entry 59; the built zip contains
|
||||||
|
only `bal/locale/it_IT/LC_MESSAGES/bal.mo` for the translations and
|
||||||
|
`external_zip_test.py` loads it; Ruff: no new errors.
|
||||||
|
|
||||||
|
**Outcome:** DONE, pending the owner's review of the Italian texts and test
|
||||||
|
of the ZIP.
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ Recorded on 2026-09-26 from the owner's answers.
|
|||||||
| invalidate | invalida il piano |
|
| invalidate | invalida il piano |
|
||||||
| invalidation (noun) | invalidazione del piano |
|
| invalidation (noun) | invalidazione del piano |
|
||||||
| anticipate / postpone | anticipa / posticipa |
|
| anticipate / postpone | anticipa / posticipa |
|
||||||
| locktime | locktime (tempo del blocco) |
|
| locktime | locktime (kept in English; owner, 2026-09-26) |
|
||||||
| fee | commissione (as Electrum) |
|
| fee | commissione (as Electrum) |
|
||||||
| broadcast | trasmetti il piano |
|
| broadcast | trasmetti il piano |
|
||||||
| sign / signed | firma / firmato (as Electrum) |
|
| sign / signed | firma / firmato (as Electrum) |
|
||||||
|
|||||||
5
babel.cfg
Normal file
5
babel.cfg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Babel extraction mapping for the BAL gettext catalog (see bal/i18n.py).
|
||||||
|
# The CLI and the wallet_util helpers stay English, like Electrum's CLI.
|
||||||
|
[ignore: bal/cli/**]
|
||||||
|
[ignore: bal/wallet_util/**]
|
||||||
|
[python: bal/**.py]
|
||||||
1918
bal/locale/bal.pot
Normal file
1918
bal/locale/bal.pot
Normal file
File diff suppressed because it is too large
Load Diff
2082
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
2082
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
53
build_zip.py
53
build_zip.py
@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
|
|||||||
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
||||||
* emits entries in a deterministic, sorted order so the archive is
|
* emits entries in a deterministic, sorted order so the archive is
|
||||||
reproducible (stable SHA-256);
|
reproducible (stable SHA-256);
|
||||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
|
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
|
||||||
|
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
|
||||||
|
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
|
||||||
|
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
|
||||||
|
(``pip install babel``); without it the build stops, so a release can
|
||||||
|
never ship without its translations by mistake.
|
||||||
|
|
||||||
The archive keeps the top-level ``bal/`` directory so that the package is
|
The archive keeps the top-level ``bal/`` directory so that the package is
|
||||||
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
||||||
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
SRC_ROOT = "bal"
|
SRC_ROOT = "bal"
|
||||||
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def compile_catalog(po_path: str) -> bytes:
|
||||||
|
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
|
||||||
|
|
||||||
|
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from babel.messages.mofile import write_mo
|
||||||
|
from babel.messages.pofile import read_po
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit(
|
||||||
|
"ERROR: Babel is needed to compile the translations "
|
||||||
|
"(pip install babel)"
|
||||||
|
) from None
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
write_mo(buf, catalog, use_fuzzy=False)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def build(out_path: str) -> None:
|
def build(out_path: str) -> None:
|
||||||
if os.path.exists(out_path):
|
if os.path.exists(out_path):
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
|
|||||||
# prune cache dirs in place so os.walk does not descend into them
|
# prune cache dirs in place so os.walk does not descend into them
|
||||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||||
for fn in filenames:
|
for fn in filenames:
|
||||||
if fn.endswith((".pyc", ".pyo")):
|
# .mo files are rebuilt from the .po sources below; the .po/.pot
|
||||||
|
# sources themselves are not shipped.
|
||||||
|
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
|
||||||
continue
|
continue
|
||||||
files.append(os.path.join(dirpath, fn))
|
files.append(os.path.join(dirpath, fn))
|
||||||
|
catalogs = {} # archive path of the .mo -> source .po
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.endswith(".po"):
|
||||||
|
po = os.path.join(dirpath, fn)
|
||||||
|
catalogs[po[: -len(".po")] + ".mo"] = po
|
||||||
files.sort()
|
files.sort()
|
||||||
|
|
||||||
with zipfile.ZipFile(
|
with zipfile.ZipFile(
|
||||||
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||||
) as z:
|
) as z:
|
||||||
for f in files:
|
entries = [(f, None) for f in files] + [
|
||||||
|
(mo, po) for mo, po in catalogs.items()
|
||||||
|
]
|
||||||
|
for f, po in sorted(entries):
|
||||||
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
||||||
|
if po is None:
|
||||||
z.write(f, arc)
|
z.write(f, arc)
|
||||||
|
else:
|
||||||
|
# Same timestamp rule as z.write(): the source file's mtime.
|
||||||
|
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
z.writestr(info, compile_catalog(po))
|
||||||
|
|
||||||
# Integrity + summary
|
# Integrity + summary
|
||||||
with zipfile.ZipFile(out_path) as z:
|
with zipfile.ZipFile(out_path) as z:
|
||||||
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
|
|||||||
|
|
||||||
data = open(out_path, "rb").read()
|
data = open(out_path, "rb").read()
|
||||||
print(f"built : {out_path}")
|
print(f"built : {out_path}")
|
||||||
print(f"files : {len(files)}")
|
print(f"files : {len(files) + len(catalogs)}")
|
||||||
|
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
|
||||||
print(f"size : {len(data)} bytes")
|
print(f"size : {len(data)} bytes")
|
||||||
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
||||||
|
|
||||||
|
|||||||
135
tests/test_translations.py
Normal file
135
tests/test_translations.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Safety checks on BAL's translation catalogs (PLAN_I18N.md, section 4.4).
|
||||||
|
|
||||||
|
Runs on every ``bal/locale/<lang>/LC_MESSAGES/bal.po`` (the sources that
|
||||||
|
build_zip.py compiles into the zip). A translation is shown to the user as
|
||||||
|
if BAL wrote it, so a wrong or malicious one must fail here, before a zip is
|
||||||
|
built:
|
||||||
|
|
||||||
|
* the catalog must parse and compile;
|
||||||
|
* the ``{}`` replacement fields must match the English text (the same rule
|
||||||
|
bal.i18n applies at run time), and so must the ``$tokens`` of the calendar
|
||||||
|
texts (``$wallet_name``, ``$heirs_complete``), which Electrum does not check;
|
||||||
|
* no Bitcoin address, e-mail address, URL or long letters-and-digits word may
|
||||||
|
appear in a translation unless the English text has the same one: the
|
||||||
|
patterns are copied from Electrum's ``electrum-locale/update.py`` (MIT
|
||||||
|
licence, Copyright (C) The Electrum developers), which rejects translations
|
||||||
|
that try to slip in an address or a link.
|
||||||
|
|
||||||
|
Run standalone (``python3 tests/test_translations.py``, prints a per-language
|
||||||
|
summary) or with pytest. Needs Babel, like build_zip.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from babel.messages.mofile import write_mo # noqa: E402
|
||||||
|
from babel.messages.pofile import read_po # noqa: E402
|
||||||
|
|
||||||
|
from bal.i18n import keeps_format_fields # noqa: E402
|
||||||
|
|
||||||
|
LOCALE_DIR = Path(__file__).resolve().parent.parent / "bal" / "locale"
|
||||||
|
|
||||||
|
# From electrum-locale/update.py (see the module docstring).
|
||||||
|
SUSPICIOUS = {
|
||||||
|
"Bitcoin address": re.compile("([13]|bc1)[a-zA-Z0-9]{30,}"),
|
||||||
|
"e-mail address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
||||||
|
"URL": re.compile(r"\S+\.\S*\w+\S*/\S+"),
|
||||||
|
"URL scheme": re.compile(r"http(s){0,1}://"),
|
||||||
|
"letters-and-digits word": re.compile(
|
||||||
|
r"(?a)(?=\w{16,})((\w*[a-zA-Z]\w*[0-9]\w*)|(\w*[0-9]\w*[a-zA-Z]\w*))"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
TOKEN = re.compile(r"\$\w+")
|
||||||
|
|
||||||
|
|
||||||
|
def _catalogs():
|
||||||
|
return sorted(LOCALE_DIR.glob("*/LC_MESSAGES/bal.po"))
|
||||||
|
|
||||||
|
|
||||||
|
def _messages(po_path):
|
||||||
|
"""Yield the translated, non-fuzzy (msgid, msgstr) pairs of a catalog."""
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
for m in catalog:
|
||||||
|
if m.id and isinstance(m.id, str) and m.string and not m.fuzzy:
|
||||||
|
yield m.id, m.string
|
||||||
|
|
||||||
|
|
||||||
|
def check_catalog(po_path):
|
||||||
|
"""Return a list of problems found in one catalog (empty = fine)."""
|
||||||
|
problems = []
|
||||||
|
for msgid, msgstr in _messages(po_path):
|
||||||
|
if not keeps_format_fields(msgid, msgstr):
|
||||||
|
problems.append("{} fields differ: {!r}".format("{}", msgid))
|
||||||
|
if sorted(TOKEN.findall(msgid)) != sorted(TOKEN.findall(msgstr)):
|
||||||
|
problems.append("$tokens differ: {!r}".format(msgid))
|
||||||
|
for name, regex in SUSPICIOUS.items():
|
||||||
|
for match in regex.finditer(msgstr):
|
||||||
|
if match.group(0) not in msgid:
|
||||||
|
problems.append(
|
||||||
|
"{} {!r} not in the English text: {!r}".format(
|
||||||
|
name, match.group(0), msgid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def summary(po_path):
|
||||||
|
"""Return (translated, untranslated, fuzzy) counts of a catalog."""
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
msgs = [m for m in catalog if m.id]
|
||||||
|
fuzzy = sum(1 for m in msgs if m.fuzzy)
|
||||||
|
translated = sum(1 for m in msgs if m.string and not m.fuzzy)
|
||||||
|
return translated, len(msgs) - translated - fuzzy, fuzzy
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_is_an_italian_catalog():
|
||||||
|
assert LOCALE_DIR / "it_IT" / "LC_MESSAGES" / "bal.po" in _catalogs()
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogs_compile():
|
||||||
|
for po in _catalogs():
|
||||||
|
with open(po, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
write_mo(buf, catalog)
|
||||||
|
assert buf.getvalue(), po
|
||||||
|
|
||||||
|
|
||||||
|
def test_translations_are_safe():
|
||||||
|
problems = [
|
||||||
|
"{}: {}".format(po.parent.parent.name, p)
|
||||||
|
for po in _catalogs()
|
||||||
|
for p in check_catalog(po)
|
||||||
|
]
|
||||||
|
assert not problems, "\n".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suspicious_patterns_are_detected():
|
||||||
|
# A translation that adds an address or a link must be caught.
|
||||||
|
regexes = SUSPICIOUS.values()
|
||||||
|
assert any(r.search("invia a bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") for r in regexes)
|
||||||
|
assert any(r.search("scarica da https://example.com") for r in regexes)
|
||||||
|
assert any(r.search("scrivi a truffa@example.com") for r in regexes)
|
||||||
|
assert not any(r.search("Firma il testamento") for r in regexes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_there_is_an_italian_catalog()
|
||||||
|
test_catalogs_compile()
|
||||||
|
test_suspicious_patterns_are_detected()
|
||||||
|
for po in _catalogs():
|
||||||
|
translated, untranslated, fuzzy = summary(po)
|
||||||
|
print(
|
||||||
|
"{}: {} translated, {} untranslated, {} fuzzy".format(
|
||||||
|
po.parent.parent.name, translated, untranslated, fuzzy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
test_translations_are_safe()
|
||||||
|
print("[OK] translation catalogs are safe")
|
||||||
Reference in New Issue
Block a user