mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-23 20:54:33 +00:00
feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled, on the grounds that no backend tells the menu label "Pictures" from the brand "Mastodon". That left the visible half of a card in English. They are filled now, and the two key sets collapse into one. The brands need somewhere to be named instead. app/i18n/keep.txt lists them, one per line, and every entry is stored as itself in every target language: no request, and never over an entry written by hand. --keep adds one-off strings, --keep-file points elsewhere. The shipped list holds the 43 product names that appear as name: or title: in config.sample.yaml. Generic labels — Pictures, Imprint, Settings, Certificates — are deliberately absent, and so are Cybermaster, Polymath and Yachtmaster, which read as brand or as job title depending on who is asking. Two of these behaviours first shipped unguarded. A test that protected a string and asserted the hand-written value survived passed either way, because a run where nothing is missing reports "complete" and never writes; and nothing exercised main(), so the keep file could stop being read without a failure. Both are covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
README.md
23
README.md
@@ -171,10 +171,25 @@ make i18n
|
|||||||
This fills the interface strings of the languages that ship no catalogue as
|
This fills the interface strings of the languages that ship no catalogue as
|
||||||
well. Existing entries are never overwritten, and a string the shipped
|
well. Existing entries are never overwritten, and a string the shipped
|
||||||
catalogue already covers is never requested, so corrections you make by hand
|
catalogue already covers is never requested, so corrections you make by hand
|
||||||
survive later runs. Only prose (`description`, `text`, `warning`, `info`, `subtitel`) is
|
survive later runs.
|
||||||
filled automatically; `name` and `title` are left to you, because a machine
|
|
||||||
cannot tell the menu label "Pictures" from the brand "Mastodon". Write those
|
`name`, `title`, `description`, `text`, `warning`, `info` and `subtitel` are
|
||||||
into the content catalogue yourself when you want them translated.
|
translated; `url`, `link_text`, `identifier` and icon classes never are.
|
||||||
|
|
||||||
|
A machine cannot tell the menu label "Pictures" from the brand "Mastodon", so
|
||||||
|
list the brands in `app/i18n/keep.txt`, one per line — they are then stored as
|
||||||
|
themselves in every language and cost no request:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Strings utils/i18n_sync.py stores as themselves instead of translating.
|
||||||
|
Mastodon
|
||||||
|
Nextcloud
|
||||||
|
freelancermap.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Add one-off entries with `--keep Foo Bar`, or point somewhere else with
|
||||||
|
`--keep-file`. A protected string never replaces an entry you already wrote by
|
||||||
|
hand.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
46
app/i18n/keep.txt
Normal file
46
app/i18n/keep.txt
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Strings utils/i18n_sync.py stores as themselves instead of translating.
|
||||||
|
# One per line; blank lines and lines starting with # are ignored.
|
||||||
|
|
||||||
|
Baserow
|
||||||
|
Big Blue Button
|
||||||
|
Bluesky
|
||||||
|
Buy me a Coffee
|
||||||
|
Discourse
|
||||||
|
Duolingo
|
||||||
|
Eversports
|
||||||
|
Facebook
|
||||||
|
Friendica
|
||||||
|
Garmin
|
||||||
|
Gitea
|
||||||
|
GitHub
|
||||||
|
GitHub Sponsors
|
||||||
|
Infinito.Nexus
|
||||||
|
Instagram
|
||||||
|
Keycloak
|
||||||
|
LDAP
|
||||||
|
LinkedIn
|
||||||
|
Mailu
|
||||||
|
Mastodon
|
||||||
|
Matomo
|
||||||
|
Matrix
|
||||||
|
Nextcloud
|
||||||
|
Open Project
|
||||||
|
Patreon
|
||||||
|
PayPal
|
||||||
|
Peertube
|
||||||
|
Pixelfed
|
||||||
|
Signal
|
||||||
|
Snipe IT
|
||||||
|
Spotify
|
||||||
|
Taiga
|
||||||
|
Telegram
|
||||||
|
Twitter
|
||||||
|
WhatsApp
|
||||||
|
XING
|
||||||
|
YouTube
|
||||||
|
Yourls
|
||||||
|
Zoom
|
||||||
|
freelancermap.de
|
||||||
|
malt
|
||||||
|
phpMyAdmin
|
||||||
|
upwork.com
|
||||||
@@ -23,9 +23,9 @@ CONTENT_DIR = I18N_DIR / "content"
|
|||||||
SOURCE_LANGUAGE = "en"
|
SOURCE_LANGUAGE = "en"
|
||||||
|
|
||||||
|
|
||||||
AUTOFILL_KEYS = frozenset({"description", "text", "warning", "info", "subtitel"})
|
TRANSLATABLE_KEYS = frozenset(
|
||||||
|
{"description", "info", "name", "subtitel", "text", "title", "warning"}
|
||||||
TRANSLATABLE_KEYS = AUTOFILL_KEYS | frozenset({"name", "title"})
|
)
|
||||||
|
|
||||||
UI_STRINGS = (
|
UI_STRINGS = (
|
||||||
"Alternatives",
|
"Alternatives",
|
||||||
|
|||||||
@@ -30,19 +30,26 @@ class TestCollectSources(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
i18n_sync.collect_sources(config),
|
i18n_sync.collect_sources(config),
|
||||||
{
|
{
|
||||||
|
"Agile Coach",
|
||||||
"I lead transformations.",
|
"I lead transformations.",
|
||||||
"Another card.",
|
"Another card.",
|
||||||
|
"Apps",
|
||||||
"Application menu",
|
"Application menu",
|
||||||
"A tagline",
|
"A tagline",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_label_and_structural_keys_are_left_alone(self):
|
def test_labels_are_collected_too(self):
|
||||||
|
config = {"name": "Pictures", "title": "Agile Coach"}
|
||||||
|
|
||||||
|
self.assertEqual(i18n_sync.collect_sources(config), {"Pictures", "Agile Coach"})
|
||||||
|
|
||||||
|
def test_structural_keys_are_left_alone(self):
|
||||||
config = {
|
config = {
|
||||||
"name": "Mastodon",
|
|
||||||
"title": "Cybermaster",
|
|
||||||
"url": "https://example.test",
|
"url": "https://example.test",
|
||||||
"link_text": "www.example.test",
|
"link_text": "www.example.test",
|
||||||
|
"identifier": "@someone@example.test",
|
||||||
|
"icon": {"class": "fa-solid fa-users"},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.assertEqual(i18n_sync.collect_sources(config), set())
|
self.assertEqual(i18n_sync.collect_sources(config), set())
|
||||||
@@ -178,6 +185,80 @@ class TestSupportedLanguages(unittest.TestCase):
|
|||||||
i18n_sync.supported_languages("http://lt", self._session(payload))
|
i18n_sync.supported_languages("http://lt", self._session(payload))
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadKeep(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.path = self.directory / "keep.txt"
|
||||||
|
|
||||||
|
def test_a_missing_file_keeps_nothing(self):
|
||||||
|
self.assertEqual(i18n_sync.read_keep(self.path), [])
|
||||||
|
|
||||||
|
def test_comments_and_blank_lines_are_ignored(self):
|
||||||
|
self.path.write_text(
|
||||||
|
"# brands\n\nMastodon\n Bluesky \n\n# more\nNextcloud\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.read_keep(self.path), ["Mastodon", "Bluesky", "Nextcloud"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_shipped_list_names_the_brands_of_the_sample_configuration(self):
|
||||||
|
shipped = i18n_sync.read_keep(i18n_sync.DEFAULT_KEEP_PATH)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(set(shipped), {"Mastodon", "Nextcloud", "Matrix"})
|
||||||
|
self.assertNotIn("Pictures", shipped)
|
||||||
|
self.assertNotIn("Imprint", shipped)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandLine(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.directory, True)
|
||||||
|
self.keep_file = self.directory / "keep.txt"
|
||||||
|
self.config = self.directory / "config.yaml"
|
||||||
|
self.config.write_text("cards: []\n", encoding="utf-8")
|
||||||
|
|
||||||
|
def _main(self, *extra):
|
||||||
|
with patch.object(i18n_sync, "sync") as sync:
|
||||||
|
i18n_sync.main(
|
||||||
|
[
|
||||||
|
"--url",
|
||||||
|
"http://lt/",
|
||||||
|
"--config",
|
||||||
|
str(self.config),
|
||||||
|
"--keep-file",
|
||||||
|
str(self.keep_file),
|
||||||
|
*extra,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return sync.call_args
|
||||||
|
|
||||||
|
def test_the_keep_file_reaches_the_sync(self):
|
||||||
|
self.keep_file.write_text("# brands\nMastodon\nNextcloud\n", encoding="utf-8")
|
||||||
|
|
||||||
|
keep = self._main().args[5]
|
||||||
|
|
||||||
|
self.assertEqual(list(keep), ["Mastodon", "Nextcloud"])
|
||||||
|
|
||||||
|
def test_the_command_line_adds_to_the_keep_file(self):
|
||||||
|
self.keep_file.write_text("Mastodon\n", encoding="utf-8")
|
||||||
|
|
||||||
|
keep = self._main("--keep", "Taiga").args[5]
|
||||||
|
|
||||||
|
self.assertEqual(list(keep), ["Mastodon", "Taiga"])
|
||||||
|
|
||||||
|
def test_a_trailing_slash_is_stripped_from_the_url(self):
|
||||||
|
self.assertEqual(self._main().args[0], "http://lt")
|
||||||
|
|
||||||
|
def test_a_backend_failure_is_reported_as_an_exit_code(self):
|
||||||
|
with patch.object(i18n_sync, "sync", side_effect=i18n_sync.BackendError("no")):
|
||||||
|
self.assertEqual(
|
||||||
|
i18n_sync.main(["--url", "http://lt", "--config", str(self.config)]), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestLoadExisting(unittest.TestCase):
|
class TestLoadExisting(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.directory = Path(tempfile.mkdtemp())
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
@@ -256,7 +337,7 @@ class TestSync(unittest.TestCase):
|
|||||||
i18n.CONTENT_DIR = self.directory
|
i18n.CONTENT_DIR = self.directory
|
||||||
self.path = self.directory / "de.yaml"
|
self.path = self.directory / "de.yaml"
|
||||||
|
|
||||||
def _run(self, sources, offered=("de",), translated="UEBERSETZT"):
|
def _run(self, sources, offered=("de",), translated="UEBERSETZT", keep=()):
|
||||||
listing = Mock(raise_for_status=Mock())
|
listing = Mock(raise_for_status=Mock())
|
||||||
listing.json.return_value = [{"code": code} for code in offered]
|
listing.json.return_value = [{"code": code} for code in offered]
|
||||||
answer = Mock(ok=True)
|
answer = Mock(ok=True)
|
||||||
@@ -264,7 +345,7 @@ class TestSync(unittest.TestCase):
|
|||||||
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
|
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
|
||||||
|
|
||||||
with patch.object(i18n_sync.requests, "Session", return_value=session):
|
with patch.object(i18n_sync.requests, "Session", return_value=session):
|
||||||
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory)
|
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory, keep)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def test_missing_entries_are_written(self):
|
def test_missing_entries_are_written(self):
|
||||||
@@ -299,6 +380,22 @@ class TestSync(unittest.TestCase):
|
|||||||
self.assertFalse(self.path.exists())
|
self.assertFalse(self.path.exists())
|
||||||
session.post.assert_not_called()
|
session.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_a_protected_string_is_stored_as_itself(self):
|
||||||
|
session = self._run(["Mastodon", "Hello"], keep=["Mastodon"])
|
||||||
|
|
||||||
|
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(catalog["Mastodon"], "Mastodon")
|
||||||
|
self.assertEqual(session.post.call_count, 1)
|
||||||
|
|
||||||
|
def test_a_protected_string_never_overwrites_an_existing_entry(self):
|
||||||
|
self.path.write_text("Mastodon: HAND-EDITED\n", encoding="utf-8")
|
||||||
|
|
||||||
|
self._run(["Mastodon", "Hello"], keep=["Mastodon"])
|
||||||
|
|
||||||
|
catalog = yaml.safe_load(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(catalog["Mastodon"], "HAND-EDITED")
|
||||||
|
self.assertEqual(catalog["Hello"], "UEBERSETZT")
|
||||||
|
|
||||||
def test_an_empty_translation_is_not_stored(self):
|
def test_an_empty_translation_is_not_stored(self):
|
||||||
self._run(["Hello"], translated="")
|
self._run(["Hello"], translated="")
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ sys.path.insert(0, str(REPO_ROOT))
|
|||||||
from app.utils import i18n # noqa: E402
|
from app.utils import i18n # noqa: E402
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATH = REPO_ROOT / "app" / "config.yaml"
|
DEFAULT_CONFIG_PATH = REPO_ROOT / "app" / "config.yaml"
|
||||||
|
DEFAULT_KEEP_PATH = i18n.I18N_DIR / "keep.txt"
|
||||||
REQUEST_TIMEOUT = 30
|
REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +39,27 @@ def collect_sources(node, key=None, found=None):
|
|||||||
elif isinstance(node, list):
|
elif isinstance(node, list):
|
||||||
for item in node:
|
for item in node:
|
||||||
collect_sources(item, key, found)
|
collect_sources(item, key, found)
|
||||||
elif isinstance(node, str) and key in i18n.AUTOFILL_KEYS and node.strip():
|
elif isinstance(node, str) and key in i18n.TRANSLATABLE_KEYS and node.strip():
|
||||||
found.add(node)
|
found.add(node)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def read_keep(path):
|
||||||
|
"""Return the strings listed in ``path``, ignoring blanks and comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: a text file with one string per line, or a path that does not exist.
|
||||||
|
"""
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
lines = path.read_text(encoding="utf-8").splitlines()
|
||||||
|
return [
|
||||||
|
stripped
|
||||||
|
for stripped in (line.strip() for line in lines)
|
||||||
|
if stripped and not stripped.startswith("#")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class BackendError(Exception):
|
class BackendError(Exception):
|
||||||
"""The translation backend answered in a way the run cannot continue from."""
|
"""The translation backend answered in a way the run cannot continue from."""
|
||||||
|
|
||||||
@@ -144,7 +161,7 @@ def translate(session, url, api_key, text, target):
|
|||||||
return translated
|
return translated
|
||||||
|
|
||||||
|
|
||||||
def sync(url, api_key, sources, targets, directory):
|
def sync(url, api_key, sources, targets, directory, keep=()):
|
||||||
"""Fill and write the catalogue of every language in ``targets``.
|
"""Fill and write the catalogue of every language in ``targets``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -153,6 +170,7 @@ def sync(url, api_key, sources, targets, directory):
|
|||||||
sources: English strings to translate.
|
sources: English strings to translate.
|
||||||
targets: language codes to fill.
|
targets: language codes to fill.
|
||||||
directory: catalogue directory to write into.
|
directory: catalogue directory to write into.
|
||||||
|
keep: strings to store as themselves instead of translating.
|
||||||
"""
|
"""
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
@@ -168,6 +186,9 @@ def sync(url, api_key, sources, targets, directory):
|
|||||||
if catalog is None:
|
if catalog is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
protected = {word: word for word in keep if word not in catalog}
|
||||||
|
catalog.update(protected)
|
||||||
|
|
||||||
shipped = (
|
shipped = (
|
||||||
{}
|
{}
|
||||||
if directory == i18n.UI_DIR
|
if directory == i18n.UI_DIR
|
||||||
@@ -190,7 +211,7 @@ def sync(url, api_key, sources, targets, directory):
|
|||||||
catalog[source] = translated
|
catalog[source] = translated
|
||||||
added += 1
|
added += 1
|
||||||
|
|
||||||
if not added:
|
if not added and not protected:
|
||||||
print(f" ! {target}: nothing translated, leaving the file untouched")
|
print(f" ! {target}: nothing translated, leaving the file untouched")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -227,6 +248,24 @@ def main(argv=None):
|
|||||||
"ui: the interface strings that ship with the project."
|
"ui: the interface strings that ship with the project."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep",
|
||||||
|
nargs="+",
|
||||||
|
default=[],
|
||||||
|
metavar="STRING",
|
||||||
|
help=(
|
||||||
|
"Store these as themselves instead of translating, "
|
||||||
|
"in addition to --keep-file."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep-file",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_KEEP_PATH,
|
||||||
|
help=(
|
||||||
|
f"One string per line, stored untranslated (default: {DEFAULT_KEEP_PATH})."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--languages",
|
"--languages",
|
||||||
nargs="+",
|
nargs="+",
|
||||||
@@ -245,8 +284,18 @@ def main(argv=None):
|
|||||||
sources = collect_sources(config) | set(i18n.UI_STRINGS)
|
sources = collect_sources(config) | set(i18n.UI_STRINGS)
|
||||||
print(f"{len(sources)} string(s) in {args.config} and the interface")
|
print(f"{len(sources)} string(s) in {args.config} and the interface")
|
||||||
|
|
||||||
|
keep = read_keep(args.keep_file) + args.keep
|
||||||
|
print(f"{len(keep)} string(s) kept untranslated")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sync(args.url.rstrip("/"), args.api_key, sources, args.languages, directory)
|
sync(
|
||||||
|
args.url.rstrip("/"),
|
||||||
|
args.api_key,
|
||||||
|
sources,
|
||||||
|
args.languages,
|
||||||
|
directory,
|
||||||
|
keep,
|
||||||
|
)
|
||||||
except BackendError as error:
|
except BackendError as error:
|
||||||
print(f"ERROR: {error}", file=sys.stderr)
|
print(f"ERROR: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
Reference in New Issue
Block a user