Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/tools-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ jobs:
python3 -m unittest -v "${modules[@]}" 2>&1 | tee "$RUNNER_TEMP/out.txt"
ran=$(grep -oE '^Ran [0-9]+ test' "$RUNNER_TEMP/out.txt" | grep -oE '[0-9]+')
echo "collected ${ran:-0} tests"
if [ "${ran:-0}" -lt 108 ]; then
echo "::error::expected at least 108 core Tools tests, collected ${ran:-0} — discovery is broken, not the suite"
if [ "${ran:-0}" -lt 117 ]; then
echo "::error::expected at least 117 core Tools tests, collected ${ran:-0} — discovery is broken, not the suite"
exit 1
fi
working-directory: Tools
Expand Down
242 changes: 200 additions & 42 deletions Strand/Resources/Localizable.xcstrings

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5678,7 +5678,7 @@ private struct RecordingStatusLight: View {
private var syncingAccessibilityLabel: String {
let n = live.syncChunksThisSession
return n > 0
? String(localized: "Syncing strap history, chunk \(n)")
? String(localized: "Syncing strap history, \(n) chunks")
: String(localized: "Syncing strap history")
}
}
Expand Down
182 changes: 182 additions & 0 deletions Tools/test_german_today_localization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Reviewed German copy for the Today surfaces on Apple and Android.

Pins the German Today vocabulary, its plural forms, and the helper that must not
overwrite reviewed catalog units. Run with::

python3 Tools/test_german_today_localization.py
"""

from __future__ import annotations

import contextlib
import importlib.util
import io
import json
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "Strand/Resources/Localizable.xcstrings"


def catalog_strings():
return json.loads(CATALOG.read_text(encoding="utf-8"))["strings"]


def german_value(key):
return catalog_strings()[key]["localizations"]["de"]["stringUnit"]["value"]


class GermanTodayLocalizationTest(unittest.TestCase):
def test_today_customization_has_real_german_plurals(self) -> None:
strings = catalog_strings()
expected = {
"%lld metrics shown": ("%lld Messwert angezeigt", "%lld Messwerte angezeigt"),
"%lld cards shown": ("%lld Karte angezeigt", "%lld Karten angezeigt"),
"%lld added": ("%lld hinzugefügt", "%lld hinzugefügt"),
}
for key, (one, other) in expected.items():
plural = strings[key]["localizations"]["de"]["variations"]["plural"]
self.assertEqual(one, plural["one"]["stringUnit"]["value"], key)
self.assertEqual(other, plural["other"]["stringUnit"]["value"], key)
self.assertIn("%lld", one)
self.assertIn("%lld", other)

def test_reviewed_today_vocabulary_is_pinned(self) -> None:
expected = {
"Shown on Today": "Auf „Heute“ angezeigt",
"Added to Today": "Zu „Heute“ hinzugefügt",
"Available": "Verfügbar",
"None added yet": "Noch keine hinzugefügt",
"Detailed tiles": "Detaillierte Kacheln",
"Good afternoon": "Guten Tag",
"Key Metrics": "Wichtige Messwerte",
"Synthesis": "Zusammenfassung",
"Readings": "Messwerte",
"Start session": "Training starten",
"Rest": "Erholung",
"Squarer tiles with a trend graph under the bar.": "Größere Kacheln mit einem Trenddiagramm unter dem Balken.",
}
self.assertEqual(expected, {key: german_value(key) for key in expected})

def test_today_customization_is_complete_for_every_supported_apple_locale(self) -> None:
strings = catalog_strings()
expected_plural_categories = {
"it": {"one", "other"},
"pl": {"one", "few", "many", "other"},
"ru": {"one", "few", "many", "other"},
"zh-Hans": {"other"},
"zh-Hant": {"other"},
}
for key in ("%lld added", "%lld cards shown", "%lld metrics shown"):
for locale, categories in expected_plural_categories.items():
plural = strings[key]["localizations"][locale]["variations"]["plural"]
self.assertEqual(categories, set(plural), f"{locale}: {key}")
for category in categories:
value = plural[category]["stringUnit"]["value"]
self.assertNotEqual(key, value, f"{locale}/{category}: {key}")
self.assertIn("%lld", value, f"{locale}/{category}: {key}")

for key in ("Added to Today", "Available", "None added yet", "Shown on Today"):
for locale in expected_plural_categories:
value = strings[key]["localizations"][locale]["stringUnit"]["value"]
self.assertNotEqual(key, value, f"{locale}: {key}")

def test_german_today_product_vocabulary_avoids_whoop_score_names_and_charge_value(self) -> None:
expected = {
"l10n_today_screen_recovery_ea924f72": "Erholung",
"today_recovery_carried": "Erholung · %1$s",
"today_card_coupled_subtitle": "Erholung, Belastung und Schlaf auf einen Blick",
"today_pending_scores_body": (
"Deine Live-Herzfrequenz kommt bereits vom Strap. Erholung, Belastung und Schlaf "
"werden in den nächsten Nächten aufgebaut und mit deinem Basiswert genauer. Für den "
"vollständigen Verlauf kannst du deinen WHOOP-Export unter „Datenquellen“ importieren; "
"er wird in etwa einer Minute ergänzt."
),
"today_training_read_explanation": "Eine Trainingseinschätzung, unabhängig von deinem Energiewert.",
}
android_de = (ROOT / "android/app/src/main/res/values-de/strings.xml").read_text(encoding="utf-8")
for key, value in expected.items():
self.assertIn(f'<string name="{key}">{value}</string>', android_de)

def test_android_today_recovery_explanation_is_localized_in_remaining_locales(self) -> None:
for locale in ("pl", "ru", "zh"):
content = (ROOT / f"android/app/src/main/res/values-{locale}/strings.xml").read_text(encoding="utf-8")
self.assertIn('<string name="today_recovery_vitals_explanation">', content, locale)

def test_pending_strap_sync_detail_has_complete_apple_localizations(self) -> None:
key = "Pending sync · strap history still offloading"
strings = catalog_strings()
self.assertEqual(
"Synchronisierung ausstehend · Strap-Verlauf wird noch übertragen",
strings[key]["localizations"]["de"]["stringUnit"]["value"],
)
self.assertEqual(
{"de", "en", "es", "fr", "it", "pl", "pt-PT", "ru", "zh-Hans", "zh-Hant"},
set(strings[key]["localizations"]),
)

def test_today_voiceover_uses_catalogued_sync_key_and_german_data_block_terms(self) -> None:
source = (ROOT / "Strand/Screens/TodayView.swift").read_text(encoding="utf-8")
self.assertIn('String(localized: "Syncing strap history, \\(n) chunks")', source)
self.assertNotIn('String(localized: "Syncing strap history, chunk \\(n)")', source)

strings = catalog_strings()
plural = strings["Syncing strap history, %lld chunks"]["localizations"]["de"]["variations"]["plural"]
self.assertEqual(
"Verlaufssynchronisierung des Straps läuft, %lld Datenblock", plural["one"]["stringUnit"]["value"]
)
self.assertEqual(
"Verlaufssynchronisierung des Straps läuft, %lld Datenblöcke", plural["other"]["stringUnit"]["value"]
)
plural = strings["Syncing strap history, %lld chunks, %@"]["localizations"]["de"]["variations"]["plural"]
self.assertEqual(
"Verlaufssynchronisierung des Straps läuft, %lld Datenblock, %@", plural["one"]["stringUnit"]["value"]
)
self.assertEqual(
"Verlaufssynchronisierung des Straps läuft, %lld Datenblöcke, %@", plural["other"]["stringUnit"]["value"]
)
self.assertEqual("%lld Datenblöcke", german_value("%lld chunks"))
self.assertEqual("%lld Datenblöcke übertragen", german_value("%lld chunks pulled"))

android_de = ET.parse(ROOT / "android/app/src/main/res/values-de/strings.xml").getroot()
chunks = android_de.find("plurals[@name='sync_chip_chunks_count']")
self.assertEqual(
{"one": "%1$d Datenblock", "other": "%1$d Datenblöcke"},
{item.get("quantity"): item.text for item in chunks.findall("item")},
)
pulled = android_de.find("string[@name='l10n_components_chunks_chunks_pulled_cec186cf']")
self.assertEqual("%1$s Datenblöcke übertragen", pulled.text)

def test_legacy_translation_helper_preserves_reviewed_catalog_units(self) -> None:
spec = importlib.util.spec_from_file_location("translate_de", ROOT / "Tools/translate-de.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)

with tempfile.TemporaryDirectory() as tmp:
fixture = Path(tmp) / "Localizable.xcstrings"
fixture.write_text(json.dumps({"strings": {
"Rest": {"localizations": {"de": {"variations": {"plural": {
"one": {"stringUnit": {"state": "translated", "value": "GEPRÜFT"}}
}}}}},
"Synthesis": {"localizations": {}},
}}), encoding="utf-8")
module.CATALOG = fixture

with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(0, module.main())
strings = json.loads(fixture.read_text(encoding="utf-8"))["strings"]

self.assertEqual(
"GEPRÜFT",
strings["Rest"]["localizations"]["de"]["variations"]["plural"]["one"]["stringUnit"]["value"],
)
self.assertEqual("Zusammenfassung", strings["Synthesis"]["localizations"]["de"]["stringUnit"]["value"])


if __name__ == "__main__":
unittest.main(verbosity=2)
49 changes: 34 additions & 15 deletions Tools/translate-de.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Inject German (de) translations into Strand/Resources/Localizable.xcstrings.
"""Fill legacy German (de) gaps in Strand/Resources/Localizable.xcstrings.

Reads the English (en) base values and adds a `de` stringUnit for each key.
Format placeholders (%@, %lld, %%) are preserved. Pure symbol / placeholder-only
strings are passed through unchanged. Re-runnable: existing `de` units are
overwritten so this file stays the source of truth for the German translation.
The string catalog is the source of truth. This compatibility helper only adds a
translation for a key that has no German localization yet; it never overwrites
reviewed string units or plural variations. The intentionally limited dictionary
is not a completeness manifest. Use the localization tests to check coverage and
placeholder/plural parity.
"""
import json
from pathlib import Path
Expand Down Expand Up @@ -142,7 +143,7 @@
"Buzzes your wrist": "Vibriert an deinem Handgelenk",
"By sport": "Nach Sportart",
"Calendar": "Kalender",
"Calibrating": "Kalibrierung",
"Calibrating": "Wird kalibriert …",
"Calm time": "Ruhezeit",
"Calories": "Kalorien",
"Cardiac": "Herz",
Expand Down Expand Up @@ -228,7 +229,7 @@
"History": "Verlauf",
"History synced %@": "Verlauf synchronisiert %@",
"Hours asleep": "Schlafstunden",
"Hours vs Needed": "Stunden vs. Bedarf",
"Hours vs Needed": "Schlafdauer im Vergleich zum Bedarf",
"How They Move Together": "Wie sie sich gemeinsam bewegen",
"How this is computed": "Wie das berechnet wird",
"How this works": "Wie das funktioniert",
Expand All @@ -245,11 +246,11 @@
"Interrogate what affects what.": "Untersuche, was was beeinflusst.",
"Interval Timer": "Intervall-Timer",
"Intervals": "Intervalle",
"Key Metrics": "Kernmetriken",
"Key Metrics": "Wichtige Messwerte",
"Last night": "Letzte Nacht",
"Last night, read in two seconds.": "Letzte Nacht, in zwei Sekunden erfasst.",
"Last Workouts": "Letzte Workouts",
"Latest": "Aktuellste",
"Latest": "Zuletzt",
"Latest reading": "Aktuellster Messwert",
"Lean body mass": "Magermasse",
"Lean Mass": "Magermasse",
Expand Down Expand Up @@ -306,7 +307,7 @@
"Normalized overlay": "Normalisierte Überlagerung",
"Not affiliated with, endorsed by, or connected to WHOOP. Interoperability software for hardware you own and your own data. Use it only with a device you own, and not in breach of any agreement that applies to you. Not a medical device.": "Nicht mit WHOOP verbunden, von WHOOP unterstützt oder zu WHOOP gehörend. Interoperabilitätssoftware für Hardware, die dir gehört, und deine eigenen Daten. Nutze sie nur mit einem Gerät, das dir gehört, und nicht unter Verletzung einer für dich geltenden Vereinbarung. Kein medizinisches Gerät.",
"Not enough data for this window.": "Nicht genug Daten für diesen Zeitraum.",
"Not enough nights yet.": "Noch nicht genug Nächte.",
"Not enough nights yet.": "Noch nicht genügend Nächte erfasst.",
"Not enough overlapping history to correlate your metrics yet.": "Noch nicht genug überlappender Verlauf, um deine Metriken zu korrelieren.",
"Not enough recent days to chart a trend yet. Import a history or keep wearing your strap.": "Noch nicht genug aktuelle Tage, um einen Trend darzustellen. Importiere eine Historie oder trage deinen Strap weiter.",
"Not synced yet": "Noch nicht synchronisiert",
Expand Down Expand Up @@ -363,7 +364,7 @@
"Reset key": "Schlüssel zurücksetzen",
"Respiratory": "Atmung",
"Respiratory rate": "Atemfrequenz",
"Rest": "Ruhe",
"Rest": "Erholung",
"Restart": "Neu starten",
"Resting heart rate": "Ruheherzfrequenz",
"Resting HR": "Ruhe-HF",
Expand Down Expand Up @@ -397,9 +398,9 @@
"Source Apple Health": "Quelle Apple Health",
"Source Whoop": "Quelle Whoop",
"Stage breakdown": "Phasen-Aufschlüsselung",
"Stages vs typical": "Phasen vs. typisch",
"Stages vs typical": "Schlafphasen im Vergleich zum Üblichen",
"Start": "Start",
"Start session": "Sitzung starten",
"Start session": "Training starten",
"State": "Zustand",
"Steps": "Schritte",
"Stop session": "Sitzung beenden",
Expand Down Expand Up @@ -459,13 +460,30 @@
"With": "Mit",
"Without": "Ohne",
"Working…": "Arbeite…",
"Workouts": "Workouts",
"Workouts": "Trainings",
"Wrist delivery isn't live yet — it needs a small on-device watcher (coming in an update) to read macOS notifications. Everything stays on this Mac. Your choices are saved now and will apply automatically once delivery ships.": "Die Zustellung ans Handgelenk ist noch nicht aktiv — sie benötigt einen kleinen Dienst auf dem Gerät (kommt in einem Update), um macOS-Mitteilungen zu lesen. Alles bleibt auf diesem Mac. Deine Auswahl ist jetzt gespeichert und wird automatisch angewendet, sobald die Zustellung verfügbar ist.",
"You're connected.": "Du bist verbunden.",
"Your live heart rate is working from the strap, and recovery, strain and sleep build from it over your next few nights of wear, sharpening as it learns your baseline. Want your full history instantly? Import your WHOOP export in Data Sources and it backfills in about a minute.": "Deine Live-Herzfrequenz funktioniert über den Strap, und Erholung, Belastung und Schlaf bauen sich daraus über deine nächsten Nächte des Tragens auf und werden schärfer, während er deine Basislinie lernt. Willst du sofort deine vollständige Historie? Importiere deinen WHOOP-Export unter Datenquellen, und er wird in etwa einer Minute nachgefüllt.",
"Your numbers, your strap, and how NOOP works. All on this Mac.": "Deine Werte, dein Strap und wie NOOP funktioniert. Alles auf diesem Mac.",
"Your strap in real time — heart rate and frames as they arrive.": "Dein Strap in Echtzeit — Herzfrequenz und Frames, sobald sie eintreffen.",
"Your thread starts here.": "Dein Verlauf beginnt hier.",
# Reviewed Today/customization vocabulary. The catalog carries the real
# singular/plural variants; these are fallback values for legacy catalogs.
"%lld added": "%lld hinzugefügt",
"%lld cards shown": "%lld Karten angezeigt",
"%lld metrics shown": "%lld Messwerte angezeigt",
"Added to Today": "Zu „Heute“ hinzugefügt",
"Available": "Verfügbar",
"Detailed tiles": "Detaillierte Kacheln",
"Good afternoon": "Guten Tag",
"Hydration": "Flüssigkeitszufuhr",
"None added yet": "Noch keine hinzugefügt",
"Readings": "Messwerte",
"Shown on Today": "Auf „Heute“ angezeigt",
"Sleep-debt ledger": "Schlafdefizit",
"Synthesis": "Zusammenfassung",
"Synced from": "Synchronisiert von",
"Syncing": "Wird synchronisiert …",
"Zone": "Zone",
}

Expand All @@ -485,7 +503,8 @@ def main() -> int:
missing.append(key)
continue
locs = entry.setdefault("localizations", {})
locs["de"] = {"stringUnit": {"state": "translated", "value": de}}
if "de" not in locs:
locs["de"] = {"stringUnit": {"state": "translated", "value": de}}

CATALOG.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
Expand Down
3 changes: 1 addition & 2 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5544,8 +5544,7 @@ private fun RecoveryContributorsSection(day: DailyMetric?, carriedDay: DailyMetr
color = Palette.sleepDeep,
)
Text(
uiString(R.string.l10n_today_screen_baselines_learned_on_device_over_14_359f6812) +
" signal against a typical adult range, not medical advice.",
uiString(R.string.today_recovery_vitals_explanation),
style = NoopType.footnote,
color = Palette.textTertiary,
)
Expand Down
Loading
Loading