Files
vogelalbum/scripts/update_translations.py
T
christian cc9e0786c2 Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
2026-10-14 18:55:23 +00:00

375 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Trägt die englischen Übersetzungen in den Katalog ein.
./venv/bin/python scripts/update_translations.py
cd web && ../venv/bin/python manage.py compilemessages -l en
Warum ein Skript und keine Handarbeit in der .po-Datei:
``makemessages`` ruft intern ``msgmerge`` auf, und das rät. Taucht ein neuer
Text auf, der einem alten ähnelt, übernimmt es dessen Übersetzung und setzt
die Marke ``#, fuzzy``. So wurde aus „Sammler" ein „Collection" (von
„Sammlung") und aus „Eine Sichtung vor 6 Uhr morgens." die Nachtschicht-Zeile.
``msgfmt`` ignoriert Fuzzy-Einträge, also fällt das im Betrieb als deutscher
Resttext auf — nicht als falsche Übersetzung. Wer dann die .po-Datei per
„ersetze msgstr, wenn leer" repariert, greift daneben: leer ist sie ja nicht.
Dieses Skript überschreibt deshalb **immer** und räumt die Fuzzy-Marke weg.
Neue Texte kommen unten in die Tabelle; was fehlt, wird gemeldet.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
PO = Path(__file__).resolve().parents[1] / "web/locale/en/LC_MESSAGES/django.po"
# Ein Paar statt eines Textes steht für einen Plural-Eintrag: (Einzahl, Mehrzahl).
TRANSLATIONS: dict[str, str | tuple[str, str]] = {
# --- Navigation und Rahmen ---
"Übersicht": "Overview",
"Sammlung": "Collection",
"Sichtungen": "Sightings",
"Abzeichen": "Badges",
"Zwischen hellem und dunklem Modus wechseln": "Switch between light and dark mode",
# Der Werbeblock im Fuss.
"Dieses Vogelalbum füllt sich von allein.":
"This bird album fills itself.",
"Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen.":
"A camera at the feeder, everything else happens on your own server — "
"no cloud, no account, no subscription. Free software, built to be rebuilt.",
"Projekt ansehen": "View the project",
# --- Fortschritt ---
"%(xp)s XP · noch %(rest)s XP bis Level %(lvl)s": "%(xp)s XP · %(rest)s XP to level %(lvl)s",
"Arten gesammelt": "species collected",
# --- Übersicht ---
"Besuche heute": "Visits today",
"Arten heute": "Species today",
"Besuche insgesamt": "Visits in total",
"Neu in der Sammlung": "New in the collection",
"letzte 7 Tage": "last 7 days",
"Neu": "New",
"Zuletzt zu Besuch": "Latest visitors",
"Stammgäste": "Regulars",
"Noch keine Sichtung aufgezeichnet.": "No sightings recorded yet.",
"Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert ist, erscheint hier der erste Gast.":
"As soon as the detector spots a bird or the first photo is handed in, "
"the first visitor appears here.",
# --- Sammlung ---
"Die Sammlung": "The Collection",
"%(a)s von %(b)s Arten": "%(a)s of %(b)s species",
"Alle": "All",
"Alle Stufen": "All tiers",
"Alle Gruppen": "All groups",
"Lebensraum": "Habitat",
"Stufe": "Tier",
"Gruppe": "Group",
"Anwenden": "Apply",
"Zurücksetzen": "Reset",
"Keine Art passt zu dieser Auswahl.": "No species matches this selection.",
"Noch nicht gesichtet": "Not yet sighted",
# --- Sichtungen ---
"%(n)s insgesamt": "%(n)s in total",
"%(n)s verdient": "%(n)s earned",
"Alle Besuche": "All visits",
"Nur bestimmte Arten": "Identified only",
"Alle Kameras": "All cameras",
"von Hand": "by hand",
"Unbestimmt": "Unidentified",
"Alternativen": "Alternatives",
"Erstfang": "First catch",
"Neuer": "Newer",
"Älter": "Older",
"Noch nichts aufgezeichnet.": "Nothing recorded yet.",
"%(a)s von %(b)s Bildern mit Vogel": "%(a)s of %(b)s frames with a bird",
# --- Artseite ---
"Steckbrief": "Profile",
"Lebensraum": "Habitat",
"Nahrung": "Diet",
"Stimme": "Voice",
"Zugverhalten": "Migration",
"Verwechslungsgefahr": "Easily confused with",
"Arten, die man nicht auf Anhieb unterscheidet":
"species you will not tell apart at a glance",
"Wann ist %(n)s da?": "When does the %(n)s show up?",
"Besuche über den Tag": "visits across the day",
"0 Uhr": "midnight",
"Noch nicht gesammelt.": "Not collected yet.",
"Sobald %(n)s vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit eurem eigenen Foto.":
"Once a %(n)s lands in front of the camera this entry unlocks — with your own photo.",
"Bestes eigenes Foto": "Best own photo",
"Referenzfoto — diese Art war noch nicht vor unserer Kamera.":
"Reference photo — this species has not been in front of our camera yet.",
"Foto": "Photo",
"%(n)s× seen": "%(n)s× seen",
# --- Statuswerte ---
"Gewicht": "Weight",
"Spannweite": "Wingspan",
"Größe": "Length",
"Seltenheit": "Rarity",
# --- Widget ---
"Zuletzt gesehen": "Last seen",
"sicher": "confident",
"Noch kein Gast": "No visitor yet",
"Arten": "species",
# --- Ränge ---
"Nestling": "Nestling",
"Ästling": "Fledgling",
"Spatzenkenner": "Sparrow Spotter",
"Meisenmeister": "Tit Master",
"Federfuchser": "Feather Nerd",
"Amselflüsterer": "Blackbird Whisperer",
"Spechtspäher": "Woodpecker Watcher",
"Falkenauge": "Falcon Eye",
"Vogelmeister": "Bird Master",
"Legende des Vogelhauses": "Legend of the Feeder",
# --- Abzeichen ---
"Erster Gast": "First Guest",
"Die allererste Sichtung im Vogelhaus.": "The very first sighting at the feeder.",
"Fünf Freunde": "High Five",
"5 verschiedene Arten gesammelt.": "5 different species collected.",
"Zweistellig": "Double Digits",
"10 verschiedene Arten gesammelt.": "10 different species collected.",
"Sammler": "Collector",
"25 verschiedene Arten gesammelt.": "25 different species collected.",
"Vogelalbum-Meister": "Vogelalbum Master",
"40 verschiedene Arten gesammelt.": "40 different species collected.",
"Frühaufsteher": "Early Bird",
"Eine Sichtung vor 6 Uhr morgens.": "A sighting before 6 in the morning.",
"Nachtschicht": "Night Shift",
"Eine Sichtung nach 21 Uhr.": "A sighting after 9 in the evening.",
"Volles Haus": "Full House",
"5 verschiedene Arten an einem Tag.": "5 different species in one day.",
"Stammgast": "Regular",
"7 Tage in Folge mindestens eine Sichtung.":
"At least one sighting on 7 consecutive days.",
"Dauergast": "Long Stay",
"Ein Besuch, der länger als eine Minute dauerte.":
"A visit lasting longer than a minute.",
"Hundert Besuche": "One Hundred Visits",
"100 Sichtungen aufgezeichnet.": "100 sightings recorded.",
"Tausend Besuche": "One Thousand Visits",
"1000 Sichtungen aufgezeichnet.": "1000 sightings recorded.",
"Klopfgeist": "The Knocker",
"Ein Specht war da.": "A woodpecker came by.",
"Greifvogel-Alarm": "Raptor Alert",
"Ein Greifvogel oder eine Eule vor der Linse.":
"A bird of prey or an owl in front of the lens.",
"Seltener Gast": "Rare Guest",
"Eine Art der Stufe 'Selten' oder höher.": "A species rated 'Rare' or above.",
# --- Blickfang der Startseite ---
# Die Überschrift richtet sich nach dem letzten Besuch. Auf Englisch wird
# daraus kein Fragesatz mit eingesetztem Tag, sondern eine Aussage — "Last
# visitor on Sunday" liest sich wie ein Mensch, "Who was a guest on
# Sunday?" wie eine Maschine.
"Vogelalbum — Übersicht": "Vogelalbum — Overview",
"Noch wartet die Kamera": "The camera is still waiting",
"Wer war heute zu Gast?": "Who stopped by today?",
"Zuletzt gestern zu Gast": "Last visitor: yesterday",
"Zuletzt am %(wochentag)s zu Gast": "Last visitor: %(wochentag)s",
"Zuletzt am %(datum)s zu Gast": "Last visitor: %(datum)s",
"Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder vom Handyfoto. Alles auf dem eigenen Server.":
"Every bird is identified, counted and collected — from a camera frame "
"or a photo off your phone. All on your own server.",
"Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne Cloud.":
"Identify, collect and catalogue birds — on your own server, "
"without a cloud.",
"ZULETZT": "LATEST",
"Noch kein Gast aufgezeichnet.": "No visitor recorded yet.",
"Der erste bestimmte Vogel steht hier.": "The first identified bird shows up here.",
"%(n)s Besuche insgesamt": "%(n)s visits in total",
# --- Zeitpunkte ---
# Wochentag und Datum kommen bereits übersetzt aus ``date_format``; hier
# steht nur noch die Fügung drumherum.
"heute, %(uhrzeit)s": "today, %(uhrzeit)s",
"gestern, %(uhrzeit)s": "yesterday, %(uhrzeit)s",
"%(wochentag)s, %(uhrzeit)s": "%(wochentag)s, %(uhrzeit)s",
"%(datum)s, %(uhrzeit)s": "%(datum)s, %(uhrzeit)s",
# --- Live ---
"Live": "Live",
"Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom.":
"If the image stays black the detector is not running — the live view "
"is relayed from the recognition's own frame stream.",
# --- Fotos einliefern ---
"Foto hochladen": "Upload photo",
"Fotos hochladen": "Upload photos",
"Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim nächsten Durchgang und trägt es in die Sammlung ein.":
"Spotted a bird? Hand in a photo here — the detector identifies it on "
"its next pass and adds it to the collection.",
"Bilder": "Images",
"Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt.":
"You can pick several images at once. Each one is identified separately.",
"Gerät": "Device",
"— ohne Zuordnung —": "— unassigned —",
"Ort (freiwillig)": "Location (optional)",
"Breite, z. B. 49.49": "Latitude, e.g. 49.49",
"Länge, z. B. 8.47": "Longitude, e.g. 8.47",
"Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die Adresse.":
"Stored rounded to about a kilometre — the area, not the address.",
"Hochladen": "Upload",
"Bitte mindestens ein Bild auswählen.": "Please select at least one image.",
"Warten auf Bestimmung": "Awaiting identification",
"Noch nicht bestimmt": "Not identified yet",
"Bleiben die Bilder länger stehen, läuft der Detector nicht.":
"If images sit here for a while, the detector is not running.",
# --- Admin ---
"Einstellungen": "Settings",
"Der Detector holt sich diese Werte selbst — er fragt <code>/api/config/</code> mit seinem Token und übernimmt Änderungen im laufenden Betrieb, ohne Neustart. Eine Stream-Adresse, die auf seiner Kommandozeile steht, behält aber Vorrang.":
"The detector fetches these values itself — it asks <code>/api/config/</code> "
"with its token and applies changes while running, without a restart. "
"A stream address given on its command line still takes precedence.",
"Das Livebild kommt vom Live-Verteiler des Detectors, nicht von der Kamera selbst — die liefert nur einen Stream, und den braucht die Erkennung. Starten mit <code>python -m detector watch … --live-port 8131</code>.":
"The live image comes from the detector's relay, not from the camera "
"itself — the camera serves only one stream, and the recognition needs "
"it. Start it with <code>python -m detector watch … --live-port 8131</code>.",
"gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)": "locked (VOGELALBUM_LIVE_ERLAUBT=0)",
"an": "on",
"aus": "off",
"an, aber keine Adresse": "on, but no address",
"an, aber Kamera stillgelegt": "on, but camera disabled",
# --- Plurale: (Einzahl, Mehrzahl) ---
"%(n)s Kamera": ("%(n)s camera", "%(n)s cameras"),
"%(n)s Foto angenommen. Es wartet auf den Detector.": (
"%(n)s photo accepted. It is waiting for the detector.",
"%(n)s photos accepted. They are waiting for the detector.",
),
}
def po_escape(text: str) -> str:
return text.replace("\\", "\\\\").replace('"', '\\"')
def po_unescape(text: str) -> str:
return text.replace('\\"', '"').replace("\\\\", "\\")
def entfuzzen(out: list[str]) -> int:
"""Die ``#, fuzzy``-Marke aus den zuletzt geschriebenen Zeilen nehmen.
Ohne das wirft ``msgfmt`` den Eintrag weg und der deutsche Text bleibt
stehen — als Fehler sieht man das nie, nur als Resttext.
"""
for k in range(len(out) - 1, max(-1, len(out) - 5), -1):
if out[k].startswith("#, "):
flags = [f for f in out[k][3:].split(", ") if f != "fuzzy"]
if flags:
out[k] = "#, " + ", ".join(flags)
else:
out.pop(k)
return 1
return 0
def main() -> int:
if not PO.exists():
print(f"Nicht gefunden: {PO}\nErst 'manage.py makemessages -l en' laufen lassen.")
return 1
lines = PO.read_text(encoding="utf-8").split("\n")
out: list[str] = []
i = 0
gesetzt = 0
entfuzzt = 0
unbekannt: list[str] = []
while i < len(lines):
line = lines[i]
if not line.startswith("msgid "):
out.append(line)
i += 1
continue
# msgid einsammeln — kann über mehrere Zeilen laufen.
blk = [line]
j = i + 1
while j < len(lines) and lines[j].startswith('"'):
blk.append(lines[j])
j += 1
key = po_unescape("".join(re.findall(r'"((?:[^"\\]|\\.)*)"', "\n".join(blk))))
# 🔴 Der Kopf der Datei hat ein leeres msgid — und nur der. Hier stand
# vorher ``or line == 'msgid ""'`` in der Bedingung oben, und das war
# ein Loch mit Ansage: **jeder lange Text beginnt ebenfalls mit
# ``msgid ""``**, weil xgettext ihn auf Folgezeilen umbricht. Damit
# übersprang dieses Skript genau die Texte, die am meisten auffallen —
# Überschriften, Fußzeile, Seitenbeschreibung — und meldete sie nicht
# einmal als fehlend. Das Werkzeug gegen die Übersetzungsfalle lief
# also selbst hinein. Geprüft wird deshalb der *eingesammelte* Text,
# nicht die erste Zeile.
if key == "":
out.extend(blk)
i = j
continue
# 🔴 Plural-Einträge wurden hier früher wortlos übersprungen — mit der
# Begründung, sie bräuchten ``msgstr[0]``/``[1]``. Stimmt, nur stand
# daneben keine Stelle, die sie füllt: „%(n)s Foto angenommen." war
# über Wochen in beiden Formen leer, und gemeldet wurde es nie.
# Jetzt tragen sie ein Paar in TRANSLATIONS, Einzahl und Mehrzahl.
if j < len(lines) and lines[j].startswith("msgid_plural"):
pblk = [lines[j]]
k = j + 1
while k < len(lines) and lines[k].startswith('"'):
pblk.append(lines[k])
k += 1
paar = TRANSLATIONS.get(key)
if not isinstance(paar, tuple):
if paar is None:
unbekannt.append(key)
out.extend(blk)
out.extend(pblk)
i = k
continue
entfuzzt += entfuzzen(out)
out.extend(blk)
out.extend(pblk)
while k < len(lines) and lines[k].startswith("msgstr["):
k += 1
while k < len(lines) and lines[k].startswith('"'):
k += 1
for nr, form in enumerate(paar):
out.append(f'msgstr[{nr}] "{po_escape(form)}"')
gesetzt += 1
i = k
continue
wert = TRANSLATIONS.get(key)
if wert is None:
unbekannt.append(key)
out.extend(blk)
i = j
continue
entfuzzt += entfuzzen(out)
out.extend(blk)
# Alte msgstr-Zeilen überspringen — unabhängig davon, was drinsteht.
while j < len(lines) and lines[j].startswith("msgstr"):
j += 1
while j < len(lines) and lines[j].startswith('"'):
j += 1
out.append(f'msgstr "{po_escape(wert)}"')
gesetzt += 1
i = j
PO.write_text("\n".join(out), encoding="utf-8")
print(f"{gesetzt} Übersetzungen gesetzt, {entfuzzt} Fuzzy-Marken entfernt.")
if unbekannt:
print(f"\n{len(unbekannt)} Texte ohne Eintrag in TRANSLATIONS:")
for u in unbekannt:
print(f" {u!r}: ...,")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())