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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,374 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user