//! German texts. English is the source language and the key: `tr("Log out")`. //! A `{}` in a key matches any text, so texts with values translate too, also the server's messages. //! A German text uses `{}` in the same order, or `{0}`, `{1}` to reorder. use std::sync::OnceLock; fn german() -> bool { static GERMAN: OnceLock = OnceLock::new(); *GERMAN.get_or_init(|| { web_sys::window() .and_then(|w| w.navigator().language()) .is_some_and(|l| l.to_lowercase().starts_with("de")) }) } /// Lets the browser pick German hyphenation, spell checking and date inputs. pub fn set_document_language() { if let Some(root) = web_sys::window() .and_then(|w| w.document()) .and_then(|d| d.document_element()) { let _ = root.set_attribute("lang", if german() { "de" } else { "en" }); } } /// The text in the user's language. Falls back to English. pub fn tr(en: &str) -> String { if !german() { return en.to_owned(); } DE.iter() .find_map(|(key, de)| matches(key, en).map(|values| fill(de, &values))) .unwrap_or_else(|| en.to_owned()) } /// The texts that the `{}` in `key` stand for, if `text` fits the key. fn matches<'a>(key: &str, text: &'a str) -> Option> { let parts: Vec<&str> = key.split("{}").collect(); let mut rest = text.strip_prefix(parts[0])?; let mut values = Vec::new(); for (i, part) in parts.iter().enumerate().skip(1) { let end = if i == parts.len() - 1 { rest.strip_suffix(part).map(str::len)? } else { rest.find(part)? }; values.push(&rest[..end]); rest = &rest[end + part.len()..]; } rest.is_empty().then_some(values) } fn fill(de: &str, values: &[&str]) -> String { let mut out = de.to_owned(); for (i, v) in values.iter().enumerate() { out = out.replace(&format!("{{{i}}}"), v); } for v in values { out = out.replacen("{}", v, 1); } out } /// English key, German text. const DE: &[(&str, &str)] = &[ ("Theme: light", "Design: hell"), ("Theme: dark", "Design: dunkel"), ("Theme: auto", "Design: automatisch"), ( "Theme: light. Click to change.", "Design: hell. Klicken zum Wechseln.", ), ( "Theme: dark. Click to change.", "Design: dunkel. Klicken zum Wechseln.", ), ( "Theme: auto. Click to change.", "Design: automatisch. Klicken zum Wechseln.", ), ("1 day", "1 Tag"), ("1 hour", "1 Stunde"), ("1 month", "1 Monat"), ("1 point.", "1 Punkt."), ("1 week", "1 Woche"), ("8 hours", "8 Stunden"), ("Add", "Hinzufügen"), ("Add a device", "Gerät hinzufügen"), ("Add a user", "Benutzer hinzufügen"), ("Add passkey", "Passkey hinzufügen"), ("Added", "Hinzugefügt"), ("Added \"{}\".", "\"{}\" hinzugefügt."), ("Admin", "Admin"), ( "All devices, also ones I add later", "Alle Geräte, auch später hinzugefügte", ), ( "Anyone with the link sees your location, without an account.", "Jeder mit dem Link sieht deinen Standort, ohne Konto.", ), ("Connect", "Verbinden"), ("Connect the app", "App verbinden"), ("Change", "Ändern"), ("Copy", "Kopieren"), ("Copy this link:", "Diesen Link kopieren:"), ("Create a guest link", "Gastlink erstellen"), ("Create admin account", "Admin-Konto erstellen"), ("Create link", "Link erstellen"), ("Create token", "Token erstellen"), ("Created", "Erstellt"), ("Created {}.", "{} erstellt."), ("Current", "Aktuell"), ("Cycling", "Fahrrad"), ("Day", "Tag"), ("Delete", "Löschen"), ( "Delete this link? Everyone who has it loses access.", "Diesen Link löschen? Alle, die ihn haben, verlieren den Zugriff.", ), ( "Delete {} and all their data?", "{} und alle zugehörigen Daten löschen?", ), ( "Delete the location history of the web app? It starts again when a browser sends a position.", "Den Standortverlauf der Web-App löschen? Er beginnt neu, sobald ein Browser einen Standort sendet.", ), ("Device", "Gerät"), ("Device name", "Gerätename"), ("Devices", "Geräte"), ( "Done. Return to the app to finish.", "Fertig. Kehre zur App zurück, um abzuschließen.", ), ("Enter a number of days.", "Gib eine Anzahl Tage ein."), ("Expires", "Läuft ab"), ("For", "Dauer"), ("From", "Von"), ("Guest links", "Gastlinks"), ("History", "Verlauf"), ("Humanitarian", "Humanitär"), ("I share my location with", "Ich teile meinen Standort mit"), ( "Keep my points for (days)", "Meine Punkte aufbewahren (Tage)", ), ("Last 24 hours", "Letzte 24 Stunden"), ("Last sent at {}.", "Zuletzt gesendet um {}."), ("Last upload", "Letzter Upload"), ("Last used", "Zuletzt verwendet"), ( "Leave the field empty to use the server setting.", "Lass das Feld leer, um die Servereinstellung zu verwenden.", ), ("Link copied.", "Link kopiert."), ("Link created and copied.", "Link erstellt und kopiert."), ("Link created.", "Link erstellt."), ("Location history", "Standortverlauf"), ("Location of {}", "Standort von {}"), ("Log in", "Anmelden"), ("Log out", "Abmelden"), ("Map", "Karte"), ("Map layers", "Kartenebenen"), ("Name", "Name"), ( "Needs a passkey, and two-factor sign-in turned off.", "Braucht einen Passkey und ausgeschaltete Zwei-Faktor-Anmeldung.", ), ("New", "Neu"), ( "New password for {}. This also removes their passkeys, disconnects their devices, turns off two-factor sign-in and logs them out.", "Neues Passwort für {}. Dabei werden auch die Passkeys entfernt, die Geräte getrennt, die Zwei-Faktor-Anmeldung ausgeschaltet und alle Sitzungen abgemeldet.", ), ("No devices yet.", "Noch keine Geräte."), ("No links yet.", "Noch keine Links."), ("No passkeys yet.", "Noch keine Passkeys."), ("Nobody.", "Niemand."), ( "None, current position only", "Keine, nur aktueller Standort", ), ("One day", "Ein Tag"), ("Only these devices:", "Nur diese Geräte:"), ("Open the app", "App öffnen"), ("Passkeys", "Passkeys"), ("Password", "Passwort"), ("Password (optional)", "Passwort (optional)"), ( "Password and passkey (two-factor)", "Passwort und Passkey (Zwei-Faktor)", ), ("Password or passkey", "Passwort oder Passkey"), ("Password removed.", "Passwort entfernt."), ("Password reset for {}.", "Passwort für {} zurückgesetzt."), ( "Password saved. Your other sessions are logged out.", "Passwort gespeichert. Deine anderen Sitzungen sind abgemeldet.", ), ( "Pick a start and an end.", "Wähle einen Anfang und ein Ende.", ), ( "Pick when the trail starts.", "Wähle, wann die Spur beginnt.", ), ( "Points older than this are deleted now. Continue?", "Ältere Punkte werden jetzt gelöscht. Fortfahren?", ), ("Precision", "Genauigkeit"), ("Remove", "Entfernen"), ( "Remove device \"{}\" and delete its location history? It can no longer upload positions.", "Gerät \"{}\" entfernen und seinen Standortverlauf löschen? Es kann dann keine Standorte mehr senden.", ), ("Remove password", "Passwort entfernen"), ( "Remove the passkey \"{}\"?", "Den Passkey \"{}\" entfernen?", ), ( "Remove your password? You then sign in with a passkey only.", "Dein Passwort entfernen? Du meldest dich dann nur noch mit einem Passkey an.", ), ("Repeat new", "Neues wiederholen"), ("Repeat password", "Passwort wiederholen"), ("Reset password", "Passwort zurücksetzen"), ("Role", "Rolle"), ("Satellite", "Satellit"), ("Save", "Speichern"), ("Saved.", "Gespeichert."), ("Set password", "Passwort setzen"), ("Seconds between sends", "Sekunden zwischen zwei Sendungen"), ("Security", "Sicherheit"), ( "Select a person to show their trail.", "Wähle eine Person, um ihre Spur zu zeigen.", ), ("Send every", "Senden alle"), ("Send my location now", "Meinen Standort jetzt senden"), ("Settings", "Einstellungen"), ("Share", "Teilen"), ("Share from this browser", "Aus diesem Browser teilen"), ("Share my location", "Meinen Standort teilen"), ("Shared location", "Geteilter Standort"), ("Shared with me", "Mit mir geteilt"), ("Sharing", "Teilen"), ( "Sharing again with the same person replaces the settings.", "Erneutes Teilen mit derselben Person ersetzt die Einstellungen.", ), ("Show location", "Standort anzeigen"), ("Shows", "Zeigt"), ("Sign in with a passkey", "Mit Passkey anmelden"), ("Sign-in", "Anmeldung"), ("Sign-in failed.", "Anmeldung fehlgeschlagen."), ("Since", "Seit"), ("Stop", "Beenden"), ( "Stop seeing the location of {}?", "Den Standort von {} nicht mehr sehen?", ), ( "Stop sharing your location with {}?", "Deinen Standort nicht mehr mit {} teilen?", ), ( "The Android app needs no token. Enter this address in the app and sign in:", "Die Android-App braucht kein Token. Gib diese Adresse in der App ein und melde dich an:", ), ( "The new passwords do not match.", "Die neuen Passwörter stimmen nicht überein.", ), ( "The opentracker app asks to upload positions to your account as a new device.", "Die opentracker-App möchte als neues Gerät Standorte an dein Konto senden.", ), ( "The passkey was not used ({}).", "Der Passkey wurde nicht verwendet ({}).", ), ( "The passkey was not used.", "Der Passkey wurde nicht verwendet.", ), ( "The passwords do not match.", "Die Passwörter stimmen nicht überein.", ), ( "The range can be at most 31 days.", "Der Zeitraum darf höchstens 31 Tage lang sein.", ), ( "The server keeps points for {} days. You can choose a shorter time.", "Der Server bewahrt Punkte {} Tage auf. Du kannst eine kürzere Zeit wählen.", ), ( "The server keeps points forever. You can choose a limit.", "Der Server bewahrt Punkte unbegrenzt auf. Du kannst eine Grenze wählen.", ), ( "The start must be before the end.", "Der Anfang muss vor dem Ende liegen.", ), ( "The token is shown only once. Set up the device with:", "Das Token wird nur einmal angezeigt. Richte das Gerät so ein:", ), ( "The web app, in any browser", "Die Web-App, in jedem Browser", ), ( "This account also needs its password.", "Dieses Konto braucht auch sein Passwort.", ), ( "This browser cannot share its location.", "Dieser Browser kann seinen Standort nicht teilen.", ), ( "This device was removed. Connect the app again.", "Dieses Gerät wurde entfernt. Verbinde die App erneut.", ), ( "This link is incomplete. Start again in the app.", "Dieser Link ist unvollständig. Beginne erneut in der App.", ), ( "This link is no longer valid.", "Dieser Link ist nicht mehr gültig.", ), ( "This link is no longer valid. It may have expired or been deleted.", "Dieser Link ist nicht mehr gültig. Er ist vielleicht abgelaufen oder wurde gelöscht.", ), ("This link works until {}.", "Dieser Link gilt bis {}."), ( "This page is for the opentracker app.", "Diese Seite ist für die opentracker-App.", ), ( "This shared location is protected with a password.", "Dieser geteilte Standort ist mit einem Passwort geschützt.", ), ( "The location is not available.", "Der Standort ist nicht verfügbar.", ), ( "User denied Geolocation", "Der Standortzugriff wurde abgelehnt.", ), ("Time range", "Zeitraum"), ("To", "Bis"), ( "Too many failed attempts. Try again in 15 minutes.", "Zu viele Fehlversuche. Versuche es in 15 Minuten erneut.", ), ("Topographic", "Topografisch"), ("Trail", "Spur"), ( "Two-factor sign-in needs a password and at least one passkey.", "Die Zwei-Faktor-Anmeldung braucht ein Passwort und mindestens einen Passkey.", ), ("User", "Benutzer"), ("Username", "Benutzername"), ("Users", "Benutzer"), ( "Welcome. Create the admin account to finish the setup.", "Willkommen. Erstelle das Admin-Konto, um die Einrichtung abzuschließen.", ), ("With", "Mit"), ("Wrong password.", "Falsches Passwort."), ( "Wrong username or password.", "Falscher Benutzername oder falsches Passwort.", ), ("Zoom in", "Vergrößern"), ("Zoom out", "Verkleinern"), ("about 1 km", "etwa 1 km"), ("about 10 km", "etwa 10 km"), ("about 100 m", "etwa 100 m"), ("admin", "Admin"), ("all devices", "alle Geräte"), ("current position only", "nur aktueller Standort"), ("exact", "genau"), ("for Anna", "für Anna"), ("forever", "unbegrenzt"), ("from now on", "ab jetzt"), ("full history", "gesamter Verlauf"), ("full trail", "ganze Spur"), ("laptop, phone, key", "Laptop, Handy, Schlüssel"), ("never", "nie"), ("no position yet", "noch kein Standort"), ( "none, current position only", "keine, nur aktueller Standort", ), ("password", "Passwort"), ("phone", "Handy"), ("seconds", "Sekunden"), ("since a time", "seit einem Zeitpunkt"), ("some devices", "einige Geräte"), ("unnamed", "ohne Namen"), ("until I stop it", "bis ich es beende"), ("user", "Benutzer"), ("username", "Benutzername"), ("{} is now an admin.", "{} ist jetzt Admin."), ("{} is now a user.", "{} ist jetzt Benutzer."), ("Unauthorized", "Nicht angemeldet"), ("Forbidden", "Nicht erlaubt"), ("Not Found", "Nicht gefunden"), ( "Too Many Requests", "Zu viele Anfragen. Versuche es später erneut.", ), ("Internal Server Error", "Interner Serverfehler"), ( "add a passkey before removing your password", "füge einen Passkey hinzu, bevor du dein Passwort entfernst", ), ( "add a passkey before turning on two-factor sign-in", "füge einen Passkey hinzu, bevor du die Zwei-Faktor-Anmeldung einschaltest", ), ( "challenge must be a SHA-256 in hex", "die Challenge muss ein SHA-256 in Hex sein", ), ( "device name must have 1 to 100 characters", "der Gerätename muss 1 bis 100 Zeichen haben", ), ( "expiry must be in the future", "der Ablauf muss in der Zukunft liegen", ), ("no Host header", "kein Host-Header"), ("no such device", "dieses Gerät gibt es nicht"), ("no such user", "diesen Benutzer gibt es nicht"), ( "password needs at least 8 characters", "das Passwort braucht mindestens 8 Zeichen", ), ( "passkeys need a domain name, not an IP address", "Passkeys brauchen einen Domainnamen, keine IP-Adresse", ), ( "range must be 0 to 31 days", "der Zeitraum muss 0 bis 31 Tage lang sein", ), ( "retention must be at least 1 days", "die Aufbewahrung muss mindestens 1 Tag betragen", ), ("select at least one device", "wähle mindestens ein Gerät"), ( "set a password before removing your last passkey", "setze ein Passwort, bevor du deinen letzten Passkey entfernst", ), ( "set a password before turning on two-factor sign-in", "setze ein Passwort, bevor du die Zwei-Faktor-Anmeldung einschaltest", ), ( "that passkey could not be used", "dieser Passkey konnte nicht verwendet werden", ), ( "that passkey is already registered", "dieser Passkey ist schon registriert", ), ( "that took too long, please try again", "das hat zu lange gedauert, bitte versuche es erneut", ), ("that username is taken", "dieser Benutzername ist vergeben"), ( "the name can have at most 100 characters", "der Name darf höchstens 100 Zeichen haben", ), ( "the server is already set up", "der Server ist schon eingerichtet", ), ("this link has no password", "dieser Link hat kein Passwort"), ( "turn off two-factor sign-in before removing your last passkey", "schalte die Zwei-Faktor-Anmeldung aus, bevor du deinen letzten Passkey entfernst", ), ( "turn off two-factor sign-in before removing your password", "schalte die Zwei-Faktor-Anmeldung aus, bevor du dein Passwort entfernst", ), ("unreadable credential", "unlesbare Anmeldedaten"), ( "username must have 1 to 64 characters", "der Benutzername muss 1 bis 64 Zeichen haben", ), ("wrong current password", "falsches aktuelles Passwort"), ( "you cannot change your own role", "du kannst deine eigene Rolle nicht ändern", ), ( "you cannot delete your own account", "du kannst dein eigenes Konto nicht löschen", ), ( "you cannot share with yourself", "du kannst nicht mit dir selbst teilen", ), ("about {} km", "etwa {} km"), ("about {} m", "etwa {} m"), ("approximate, {} km", "ungefähr, {} km"), ("approximate, {} m", "ungefähr, {} m"), ("battery {}%", "Akku {}%"), ("network error: {}", "Netzwerkfehler: {}"), ("trail since {}", "Spur seit {}"), ( "retention must be 1 to {} days", "die Aufbewahrung muss 1 bis {} Tage betragen", ), ( "at most {} points per request", "höchstens {} Punkte pro Anfrage", ), ( "precision must be at most {} metres", "die Genauigkeit darf höchstens {} Meter betragen", ), ( "you can have at most {} passkeys", "du kannst höchstens {} Passkeys haben", ), ( "passkeys are set up for {}, but this page is {}. Set --public-url to the address you use.", "Passkeys sind für {} eingerichtet, diese Seite ist aber {}. Setze --public-url auf die Adresse, die du verwendest.", ), ("{} (you)", "{} (du)"), ("{} h ago", "vor {} h"), ("{} min ago", "vor {} min"), ("{} points.", "{} Punkte."), ("{} s ago", "vor {} s"), ( "{} shares only their current position.", "{} teilt nur den aktuellen Standort.", ), ( "{} shares the trail since {}.", "{} teilt die Spur seit {}.", ), ]; #[cfg(test)] mod tests { use super::*; #[test] fn keys_with_values_match() { assert_eq!(matches("{} points.", "12 points."), Some(vec!["12"])); assert_eq!( matches( "Theme: {}. Click to change.", "Theme: dark. Click to change." ), Some(vec!["dark"]) ); assert_eq!(matches("{} points.", "12 points!"), None); assert_eq!(matches("Log out", "Log out"), Some(vec![])); assert_eq!(fill("{1}, dann {0}", &["a", "b"]), "b, dann a"); assert_eq!(fill("{} Punkte", &["3"]), "3 Punkte"); } #[test] fn german_texts_keep_every_value() { for (en, de) in DE { let values = en.matches("{}").count(); let used = de.matches("{}").count() + (0..values) .filter(|i| de.contains(&format!("{{{i}}}"))) .count(); assert_eq!(values, used, "{en}"); } } }