"""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 /api/config/ 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 /api/config/ " "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 python -m detector watch … --live-port 8131.": "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 python -m detector watch … --live-port 8131.", "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())