diff --git a/.github/workflows/tools-python.yml b/.github/workflows/tools-python.yml
index 5467862993..14090fcf66 100644
--- a/.github/workflows/tools-python.yml
+++ b/.github/workflows/tools-python.yml
@@ -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
diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings
index e9ff3d8ada..256aac029d 100644
--- a/Strand/Resources/Localizable.xcstrings
+++ b/Strand/Resources/Localizable.xcstrings
@@ -5,17 +5,53 @@
"de": {"stringUnit": {"state": "translated", "value": "7-Tage-Trend"}}, "es": {"stringUnit": {"state": "translated", "value": "Tendencia de 7 días"}}, "fr": {"stringUnit": {"state": "translated", "value": "Tendance sur 7 jours"}}, "it": {"stringUnit": {"state": "translated", "value": "Tendenza a 7 giorni"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trend 7-dniowy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Tendência de 7 dias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренд за 7 дней"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "7 天趋势"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "7 天趨勢"}}
} },
"Added to Today": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "Zu Heute hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadido a Hoy"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouté à Aujourd'hui"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiunto a Oggi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano do Dzisiaj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionado a Hoje"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлено в «Сегодня»"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已添加到今天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已新增至今天"}}
- } },
+ "de": { "stringUnit": { "state": "translated", "value": "Zu „Heute“ hinzugefügt" } },
+ "en": { "stringUnit": { "state": "translated", "value": "Added to Today" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Añadidas a Hoy" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Ajoutées à Aujourd’hui" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Adicionados a Hoje" } }
+ ,"it": { "stringUnit": { "state": "translated", "value": "Aggiunti a Oggi" } }
+ ,"pl": { "stringUnit": { "state": "translated", "value": "Dodane do widoku Dzisiaj" } }
+ ,"ru": { "stringUnit": { "state": "translated", "value": "Добавлено в раздел «Сегодня»" } }
+ ,"zh-Hans": { "stringUnit": { "state": "translated", "value": "已添加到“今天”" } }
+ ,"zh-Hant": { "stringUnit": { "state": "translated", "value": "已加入「今天」" } }
+ } },
"Available": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "Verfügbar"}}, "es": {"stringUnit": {"state": "translated", "value": "Disponible"}}, "fr": {"stringUnit": {"state": "translated", "value": "Disponible"}}, "it": {"stringUnit": {"state": "translated", "value": "Disponibile"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dostępne"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Disponível"}}, "ru": {"stringUnit": {"state": "translated", "value": "Доступно"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "可用"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "可用"}}
- } },
+ "de": { "stringUnit": { "state": "translated", "value": "Verfügbar" } },
+ "en": { "stringUnit": { "state": "translated", "value": "Available" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Disponibles" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Disponibles" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Disponíveis" } }
+ ,"it": { "stringUnit": { "state": "translated", "value": "Disponibili" } }
+ ,"pl": { "stringUnit": { "state": "translated", "value": "Dostępne" } }
+ ,"ru": { "stringUnit": { "state": "translated", "value": "Доступные" } }
+ ,"zh-Hans": { "stringUnit": { "state": "translated", "value": "可用" } }
+ ,"zh-Hant": { "stringUnit": { "state": "translated", "value": "可用" } }
+ } },
"None added yet": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "Noch nichts hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "Aún no has añadido ninguno"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun ajouté pour l'instant"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuno aggiunto finora"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nic jeszcze nie dodano"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ainda não foi adicionado nenhum"}}, "ru": {"stringUnit": {"state": "translated", "value": "Пока ничего не добавлено"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "尚未添加任何内容"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "尚未新增任何項目"}}
- } },
+ "de": { "stringUnit": { "state": "translated", "value": "Noch keine hinzugefügt" } },
+ "en": { "stringUnit": { "state": "translated", "value": "None added yet" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Aún no se ha añadido ninguna" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Aucune carte ajoutée" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Ainda não foi adicionado nenhum" } }
+ ,"it": { "stringUnit": { "state": "translated", "value": "Nessun elemento ancora aggiunto" } }
+ ,"pl": { "stringUnit": { "state": "translated", "value": "Nie dodano jeszcze żadnych elementów" } }
+ ,"ru": { "stringUnit": { "state": "translated", "value": "Пока ничего не добавлено" } }
+ ,"zh-Hans": { "stringUnit": { "state": "translated", "value": "尚未添加任何项目" } }
+ ,"zh-Hant": { "stringUnit": { "state": "translated", "value": "尚未加入任何項目" } }
+ } },
"Shown on Today": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "Auf Heute angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "Mostrado en Hoy"}}, "fr": {"stringUnit": {"state": "translated", "value": "Affiché dans Aujourd'hui"}}, "it": {"stringUnit": {"state": "translated", "value": "Mostrato in Oggi"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazywane w Dzisiaj"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Mostrado em Hoje"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано в «Сегодня»"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示在今天"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示在今天"}}
- } },
+ "de": { "stringUnit": { "state": "translated", "value": "Auf „Heute“ angezeigt" } },
+ "en": { "stringUnit": { "state": "translated", "value": "Shown on Today" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Mostradas en Hoy" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Affichées dans Aujourd’hui" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Apresentados em Hoje" } }
+ ,"it": { "stringUnit": { "state": "translated", "value": "Mostrati in Oggi" } }
+ ,"pl": { "stringUnit": { "state": "translated", "value": "Wyświetlane w widoku Dzisiaj" } }
+ ,"ru": { "stringUnit": { "state": "translated", "value": "Показано в разделе «Сегодня»" } }
+ ,"zh-Hans": { "stringUnit": { "state": "translated", "value": "显示在“今天”中" } }
+ ,"zh-Hant": { "stringUnit": { "state": "translated", "value": "顯示於「今天」" } }
+ } },
"last night": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "letzte Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "anoche"}}, "fr": {"stringUnit": {"state": "translated", "value": "la nuit dernière"}}, "it": {"stringUnit": {"state": "translated", "value": "la notte scorsa"}}, "pl": {"stringUnit": {"state": "translated", "value": "ostatniej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "ontem à noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "прошлой ночью"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "昨晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "昨晚"}}
} },
@@ -28,9 +64,20 @@
"toggle ON · no estimate yet": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Schalter AN · noch keine Schätzung"}}, "es": {"stringUnit": {"state": "translated", "value": "interruptor activado · aún sin estimación"}}, "fr": {"stringUnit": {"state": "translated", "value": "commutateur activé · pas encore d'estimation"}}, "it": {"stringUnit": {"state": "translated", "value": "interruttore attivo · nessuna stima"}}, "pl": {"stringUnit": {"state": "translated", "value": "przełącznik włączony · brak szacunku"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "interruptor ligado · ainda sem estimativa"}}, "ru": {"stringUnit": {"state": "translated", "value": "переключатель включён · оценки пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开关已开启 · 尚无估算值"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開關已開啟 · 尚無估算值"}}
} },
- "Pending sync · strap history still offloading": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "Synchronisierung ausstehend · Strap-Verlauf wird noch übertragen"}}, "es": {"stringUnit": {"state": "translated", "value": "Sincronización pendiente · el historial de la banda aún se está descargando"}}, "fr": {"stringUnit": {"state": "translated", "value": "Synchronisation en attente · l'historique du bracelet est encore en cours de transfert"}}, "it": {"stringUnit": {"state": "translated", "value": "Sincronizzazione in attesa · la cronologia della fascia è ancora in trasferimento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Oczekiwanie na synchronizację · historia opaski wciąż jest pobierana"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sincronização pendente · o histórico da pulseira ainda está a ser transferido"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ожидание синхронизации · история ремешка ещё выгружается"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "等待同步 · 手环历史数据仍在传输"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "等待同步 · 手環歷史資料仍在傳輸"}}
- } },
+ "Pending sync · strap history still offloading": {
+ "localizations": {
+ "de": { "stringUnit": { "state": "translated", "value": "Synchronisierung ausstehend · Strap-Verlauf wird noch übertragen" } },
+ "en": { "stringUnit": { "state": "translated", "value": "Pending sync · strap history still offloading" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Sincronización pendiente · el historial de la correa aún se está transfiriendo" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Synchronisation en attente · l’historique du bracelet est toujours en cours de transfert" } },
+ "it": { "stringUnit": { "state": "translated", "value": "Sincronizzazione in sospeso · trasferimento della cronologia del cinturino ancora in corso" } },
+ "pl": { "stringUnit": { "state": "translated", "value": "Synchronizacja oczekuje · historia opaski jest nadal przesyłana" } },
+ "pt-PT": { "stringUnit": { "state": "translated", "value": "Sincronização pendente · o histórico da pulseira ainda está a ser transferido" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Ожидание синхронизации · история ремешка ещё передаётся" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "等待同步 · 表带历史记录仍在传输" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "等待同步 · 錶帶歷史記錄仍在傳輸" } }
+ }
+ },
"Strap battery charging, no reading yet": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Strap-Akku wird geladen, noch kein Messwert"}}, "es": {"stringUnit": {"state": "translated", "value": "Batería de la banda cargando, aún sin lectura"}}, "fr": {"stringUnit": {"state": "translated", "value": "Batterie du bracelet en charge, pas encore de relevé"}}, "it": {"stringUnit": {"state": "translated", "value": "Batteria della fascia in carica, nessuna lettura"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bateria opaski ładuje się, brak odczytu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Bateria da pulseira a carregar, ainda sem leitura"}}, "ru": {"stringUnit": {"state": "translated", "value": "Ремешок заряжается, показаний пока нет"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "手环正在充电,尚无读数"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "手環正在充電,尚無讀數"}}
} },
@@ -50,14 +97,137 @@
"de": {"stringUnit": {"state": "translated", "value": "Strap-Verlauf wird synchronisiert, Block %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Sincronizando historial de la banda, bloque %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Synchronisation de l'historique du bracelet, bloc %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Sincronizzazione della cronologia della fascia, blocco %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Synchronizacja historii opaski, fragment %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A sincronizar o histórico da pulseira, bloco %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Синхронизация истории ремешка, фрагмент %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在同步手环历史数据,第 %lld 块"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在同步手環歷史資料,第 %lld 塊"}}
} },
"%lld cards shown": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "%lld Karten angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld tarjetas mostradas"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld cartes affichées"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld schede mostrate"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazane karty: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld cartões mostrados"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано карточек: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示 %lld 张卡片"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示 %lld 張卡片"}}
- } },
+ "de": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld Karte angezeigt" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld Karten angezeigt" } }
+ } } },
+ "en": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld card shown" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld cards shown" } }
+ } } },
+ "es": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld tarjeta mostrada" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld tarjetas mostradas" } }
+ } } },
+ "fr": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld carte affichée" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld cartes affichées" } }
+ } } },
+ "pt-PT": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld cartão apresentado" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld cartões apresentados" } }
+ } } },
+ "it": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld scheda mostrata" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld schede mostrate" } }
+ } } },
+ "pl": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld karta wyświetlana" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld karty wyświetlane" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld kart wyświetlanych" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld karty wyświetlanej" } }
+ } } },
+ "ru": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld карточка показана" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld карточки показаны" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld карточек показано" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld карточки показано" } }
+ } } },
+ "zh-Hans": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已显示 %lld 张卡片" } }
+ } } },
+ "zh-Hant": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已顯示 %lld 張卡片" } }
+ } } }
+ } },
"%lld added": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "%lld hinzugefügt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld añadidos"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld ajoutés"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld aggiunti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld adicionados"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавлено: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已添加 %lld 个"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已新增 %lld 個"}}
- } },
+ "de": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld hinzugefügt" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld hinzugefügt" } }
+ } } },
+ "en": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld added" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld added" } }
+ } } },
+ "es": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld añadida" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld añadidas" } }
+ } } },
+ "fr": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld ajoutée" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld ajoutées" } }
+ } } },
+ "pt-PT": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld adicionado" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld adicionados" } }
+ } } },
+ "it": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld elemento aggiunto" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld elementi aggiunti" } }
+ } } },
+ "pl": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld dodany element" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld dodane elementy" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld dodanych elementów" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld dodanego elementu" } }
+ } } },
+ "ru": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld элемент добавлен" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld элемента добавлено" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld элементов добавлено" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld элемента добавлено" } }
+ } } },
+ "zh-Hans": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已添加 %lld 项" } }
+ } } },
+ "zh-Hant": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已加入 %lld 項" } }
+ } } }
+ } },
"%lld metrics shown": { "localizations": {
- "de": {"stringUnit": {"state": "translated", "value": "%lld Werte angezeigt"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld métricas mostradas"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld mesures affichées"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld metriche mostrate"}}, "pl": {"stringUnit": {"state": "translated", "value": "Pokazane wskaźniki: %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld métricas mostradas"}}, "ru": {"stringUnit": {"state": "translated", "value": "Показано показателей: %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "显示 %lld 项指标"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "顯示 %lld 項指標"}}
- } },
+ "de": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld Messwert angezeigt" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld Messwerte angezeigt" } }
+ } } },
+ "en": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld metric shown" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld metrics shown" } }
+ } } },
+ "es": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld métrica mostrada" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld métricas mostradas" } }
+ } } },
+ "fr": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld mesure affichée" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld mesures affichées" } }
+ } } },
+ "pt-PT": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld métrica apresentada" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld métricas apresentadas" } }
+ } } },
+ "it": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld metrica mostrata" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld metriche mostrate" } }
+ } } },
+ "pl": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld wskaźnik wyświetlany" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld wskaźniki wyświetlane" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld wskaźników wyświetlanych" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld wskaźnika wyświetlanego" } }
+ } } },
+ "ru": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%lld показатель отображается" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%lld показателя отображаются" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%lld показателей отображаются" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%lld показателя отображается" } }
+ } } },
+ "zh-Hans": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已显示 %lld 项指标" } }
+ } } },
+ "zh-Hant": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "已顯示 %lld 項指標" } }
+ } } }
+ } },
"Looks like a workout around %@-%@ (avg HR %lld, %lld min). Save it?": { "localizations": {
"de": {"stringUnit": {"state": "translated", "value": "Sieht nach einem Workout zwischen %@ und %@ aus (Ø HF %lld, %lld Min.). Speichern?"}}, "es": {"stringUnit": {"state": "translated", "value": "Parece un entrenamiento entre %@ y %@ (FC media %lld, %lld min). ¿Guardarlo?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cela ressemble à une séance entre %@ et %@ (FC moy. %lld, %lld min). L'enregistrer ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Sembra un allenamento tra %@ e %@ (FC media %lld, %lld min). Salvarlo?"}}, "pl": {"stringUnit": {"state": "translated", "value": "To wygląda na trening między %@ a %@ (śr. tętno %lld, %lld min). Zapisać?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Parece um treino entre %@ e %@ (FC média %lld, %lld min). Guardar?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Похоже на тренировку с %@ до %@ (средний пульс %lld, %lld мин). Сохранить?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "看起来是一次 %@-%@ 的锻炼(平均心率 %lld,%lld 分钟)。保存吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "看起來是一次 %@-%@ 的鍛鍊(平均心率 %lld,%lld 分鐘)。要儲存嗎?"}}
} },
@@ -10332,7 +10502,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "%lld Chunks"
+ "value": "%lld Datenblöcke"
}
},
"es": {
@@ -10390,7 +10560,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "%lld Chunks geladen"
+ "value": "%lld Datenblöcke übertragen"
}
},
"es": {
@@ -60079,7 +60249,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Detaillierte Fliesen"
+ "value": "Detaillierte Kacheln"
}
},
"es": {
@@ -74896,7 +75066,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Guten Nachmittag"
+ "value": "Guten Tag"
}
},
"es": {
@@ -89114,7 +89284,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Kernmetriken"
+ "value": "Wichtige Messwerte"
}
},
"en": {
@@ -129672,7 +129842,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Lesungen"
+ "value": "Messwerte"
}
},
"es": {
@@ -129704,18 +129874,6 @@
"state": "translated",
"value": "Измерения"
}
- },
- "zh-Hans": {
- "stringUnit": {
- "state": "translated",
- "value": "读数"
- }
- },
- "zh-Hant": {
- "stringUnit": {
- "state": "translated",
- "value": "讀數"
- }
}
}
},
@@ -150457,7 +150615,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Squarer-Kacheln mit einem Trendgraphen unter der Bar."
+ "value": "Größere Kacheln mit einem Trenddiagramm unter dem Balken."
}
},
"es": {
@@ -151803,7 +151961,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Sitzung starten"
+ "value": "Training starten"
}
},
"en": {
@@ -157920,13 +158078,13 @@
"one": {
"stringUnit": {
"state": "translated",
- "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk"
+ "value": "Verlaufssynchronisierung des Straps läuft, %lld Datenblock"
}
},
"other": {
"stringUnit": {
"state": "translated",
- "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks"
+ "value": "Verlaufssynchronisierung des Straps läuft, %lld Datenblöcke"
}
}
}
@@ -158314,13 +158472,13 @@
"one": {
"stringUnit": {
"state": "translated",
- "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunk, %@"
+ "value": "Verlaufssynchronisierung des Straps läuft, %lld Datenblock, %@"
}
},
"other": {
"stringUnit": {
"state": "translated",
- "value": "Verlaufssynchronisierung des Straps läuft, %lld Chunks, %@"
+ "value": "Verlaufssynchronisierung des Straps läuft, %lld Datenblöcke, %@"
}
}
}
@@ -158815,7 +158973,7 @@
"de": {
"stringUnit": {
"state": "translated",
- "value": "Synthese"
+ "value": "Zusammenfassung"
}
},
"es": {
diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift
index dc67eb45b2..e9edcf4be1 100644
--- a/Strand/Screens/TodayView.swift
+++ b/Strand/Screens/TodayView.swift
@@ -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")
}
}
diff --git a/Tools/test_german_today_localization.py b/Tools/test_german_today_localization.py
new file mode 100644
index 0000000000..88b70d5605
--- /dev/null
+++ b/Tools/test_german_today_localization.py
@@ -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'{value}', 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('', 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)
diff --git a/Tools/translate-de.py b/Tools/translate-de.py
index f545d30ff7..fafe20fb53 100644
--- a/Tools/translate-de.py
+++ b/Tools/translate-de.py
@@ -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
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
}
@@ -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")
diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt
index 50c8ac8b63..4e2754e499 100644
--- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt
@@ -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,
)
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index dee6a56639..ca2ee38fad 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -60,7 +60,7 @@
Atmen
- Neueste Workouts
+ Neueste Trainings
Heute anpassen
Anpassen
Ordne sichtbare Elemente neu an oder verschiebe einen Abschnitt nach „Ausgeblendet“, ohne ihn zu löschen.
@@ -972,7 +972,7 @@
Das startet den etwa 4-nächtigen Aufbau für Ladung und deine HRV-Basislinie neu. Dein Verlauf bleibt.
AtmosphärePhase
Heute vereinbaren
- Kalibrierung
+ Wird kalibriert …
Seit %1$d Tagen keine neuen Nächte von deinem Strap. Prüfe, ob er verbunden ist und Daten speichert.
%1$s von %2$s Nächten
Wähle, welche Karten auf Heute angezeigt werden, und ordne sie mit den Pfeilen neu an.
@@ -982,8 +982,8 @@
Fertig
%1$s, %2$s, %3$s,
Halte einen Abschnitt und ziehe ihn zum Nachbestellen - hier oder direkt auf den Today-Karten.
- So wird Ladung berechnet
- So wird Ladung berechnet. Die Methode hinter dem Wert.
+ So wird Energie berechnet
+ So wird Energie berechnet. Die Methode hinter dem Wert.
%1$s %2$s
%1$s %2$s
%1$s nach unten verschieben
@@ -996,21 +996,21 @@
Schnellaktionen
Bereitschaft: %1$s
Zurücksetzen
- Zurück zum Workout
- Sieh, wie Ladung, Anstrengung und Ruhe berechnet werden und wie sie sich von WHOOP unterscheiden.
+ Zurück zum Training
+ Sieh, wie Energie, Belastung und Erholung berechnet werden und wie sie sich von WHOOP unterscheiden.
Sieh, wie es funktioniert
%1$s anzeigen
%1$s pts
%1$s. %2$s
Die Methode hinter dem Wert, nicht die heutigen Zahlen.
%1$s, Beta. %2$s
- Was deine Ladung geprägt hat
+ Was deine Energie geprägt hat
wordmark-dx
Wortmarkenrot
wordmark-sx
wordmark-sy
- WORKOUT LÄUFT
- Workout in progress, %1$s, %2$s. Zurück zum Workout.
+ TRAINING LÄUFT
+ Training läuft, %1$s, %2$s. Zurück zum Training.
%1$s%2$s
Alle löschen
Früher
@@ -1379,7 +1379,7 @@
Updates
Coach
%1$s entfernen
- %1$s Brocken gezogen
+ %1$s Datenblöcke übertragen
Verringere %1$s
Glühringfraktion
Glühwert
@@ -1688,7 +1688,6 @@
Fehlerbericht
Anordnung
Heute Abschnitte arrangieren
- Baselines lernten über 14 Tage auf dem Gerät. Balken sind eine ungefähre Lektüre von jedem
Blutsauerstoff
Atemzüge pro Minute
Kalorien
@@ -1697,11 +1696,11 @@
%1$s %2$s %3$s
%1$s
%1$s, %2$s. Tippe, um einen Tag auszuwählen, wische, um den Tag zu ändern.
- Detaillierte Fliesen
+ Detaillierte Kacheln
In „Updates“ ablegen
- Jede Zeile gibt an, wie viele Punkte bewegte Ladung im Vergleich zu sitzen an deinem
+ Jede Zeile zeigt, um wie viele Punkte dieses Signal deine Energie gegenüber deinem Basiswert verändert.
Bearbeiten
- Kennzahlen bearbeiten
+ Wichtige Messwerte bearbeiten
Herzfrequenzvariabilität
Wie %1$s berechnet wird
%1$s bpm
@@ -1709,7 +1708,7 @@
%1$s nach unten verschieben
%1$s nach oben verschieben
Neue Daten hinzugefügt
- Noch keine Cardio-Last. Anstrengung baut sich auf, sobald deine Herzfrequenz in deine Anstrengungszone steigt (etwa 50 % deiner Herzfrequenzreserve). Ein ruhiger Tag liest sich ehrlich nahe null.
+ Noch keine kardiovaskuläre Belastung. Dein Belastungswert steigt, sobald deine Herzfrequenz die Belastungszone erreicht (etwa 50 % deiner Herzfrequenzreserve). Ein ruhiger Tag liegt entsprechend nahe null.
%1$s%%
Profil und Einstellungen
Erholung
@@ -1718,8 +1717,8 @@
Verlaufssynchronisierung des Straps läuft, %1$s
Verlaufssynchronisierung des Straps läuft, %1$s, %2$s
- - %1$d Chunk
- - %1$d Chunks
+ - %1$d Datenblock
+ - %1$d Datenblöcke
- %1$d Seite Rückstand bei Verbindungsaufbau
@@ -1727,21 +1726,21 @@
Verlauf des Straps vor %1$s synchronisiert
Verbunden; die Verlaufssynchronisierung ist bei diesem Strap experimentell.
- %1$s%% Ladung
+ %1$s%% Energie
Atmung
- Ruhe
+ Erholung
Ruhe-HF
Ruheherzfrequenz
Ruhe-HF
%1$s anzeigen
Schlaf
Quelle: %1$s
- Squarer-Kacheln mit einem Trendgraphen unter der Bar.
+ Größere Kacheln mit einem Trenddiagramm unter dem Balken.
Schritte
Belastung
- Synchronisiert aus
+ Synchronisiert von
ZUSAMMENFASSUNG
- %1$s Anstrengung
+ %1$s Belastung
%1$s bpm
%1$s
%1$s
@@ -2185,17 +2184,18 @@
Beruhige mich
- Charge / Effort / Erholung
- Session starten
+ Energie / Belastung / Erholung
+ Training starten
Zusammenfassung
Wichtige Messwerte
Trainingseinheiten
Herzfrequenz
- Erholungswerte
+ Vitalwerte
Deine Karten
Menstruationszyklus
Tagebuch
Hinzugefügte Karten
+ Basiswerte werden über 14 Tage auf dem Gerät gelernt. Jeder Balken zeigt näherungsweise ein Signal im Vergleich zu einem typischen Bereich für Erwachsene; dies ist keine medizinische Beratung.
Heute
24 Std.
12 Std.
@@ -2218,7 +2218,7 @@
Oura Ring
Fitbit-Tracker
Garmin-Uhr
- Workout-Dateien
+ Trainingsdateien
" + "
Belasten
Halten
@@ -2233,7 +2233,7 @@
Strap-Akku
Strap-Akku: %1$d Prozent
STABIL
- Charge-Wert · %1$s
+ Energie · %1$s
Erholung · %1$s
Übernommen · %1$s
Weniger anzeigen
@@ -2245,7 +2245,7 @@
Vergrößert · zum Verschieben ziehen
Nicht verbunden
Solltest du heute Gas geben?
- Eine Trainingseinschätzung, unabhängig von deinem Charge-Wert.
+ Eine Trainingseinschätzung, unabhängig von deinem Energiewert.
Sieht nach einem Training gegen %1$s–%2$s aus (Ø HF %3$d, %4$d Min.). Speichern?
Sieht nach einem Training gestern gegen %1$s–%2$s aus (Ø HF %3$d, %4$d Min.). Speichern?
Sieht nach einem Training am %1$s gegen %2$s–%3$s aus (Ø HF %4$d, %5$d Min.). Speichern?
@@ -2288,7 +2288,7 @@
Gewicht
Kalorien
Bereitschaft
- Trage das Band einige Nächte; danach erscheint hier deine Bereitschaftseinschätzung.
+ Trage den Strap einige Nächte; danach erscheint hier deine Bereitschaftseinschätzung.
HRV
über deinem Basiswert - gut erholt
In deinem normalen Bereich
@@ -2314,7 +2314,7 @@
Beansprucht
Eines deiner Signale fällt auf. Du kannst trainieren, aber halte es kontrolliert und sichere dir Erholung.
Bereit
- Deine Signale sind im Einklang und deine Last ist gut gestützt. Eine härtere Einheit ist heute gut abgesichert.
+ Deine Signale passen zusammen und dein Körper verkraftet die Belastung. Heute spricht nichts gegen eine härtere Einheit.
Ausgeglichen
Kein Signal fällt auf. Trainiere nach Gefühl - dein Körper bleibt stabil.
Tag ändern
@@ -2327,7 +2327,7 @@
Einflüsse auf %1$s anzeigen
%1$s-Details öffnen
Synchronisierte NOOP-Quellen anzeigen
- Workout-Details anzeigen
+ Trainingsdetails anzeigen
Einflussfaktoren
Datenquellen
Schläge pro Minute
@@ -2355,7 +2355,7 @@
Die Energie ist stark, aber dein Schlaf war zu kurz.
Erschöpft
Niedrig
- Noch keine Messwerte. Importiere deinen WHOOP-Export oder trage dein Band.
+ Noch keine Messwerte. Importiere deinen WHOOP-Export oder trage deinen Strap.
Spitze
Stabil
Stark
@@ -2388,13 +2388,13 @@
Energie, Belastung und Erholung werden nach einigen Nächten individuell.
Live aktiv. Deine Werte werden aufgebaut.
Energie, Belastung und Erholung entstehen in den nächsten Nächten.
- Deine Live-Herzfrequenz kommt bereits vom Band. Erholung, Belastung und Schlaf entstehen in den nächsten Nächten und werden 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.
- Sitzung läuft
- Sitzung beendet
- Sitzung starten
+ 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.
+ Training läuft
+ Training beendet
+ Training starten
Überwachung aktiv - solange es still bleibt, liegst du richtig.
- Zusammenfassung deiner letzten Sitzung anzeigen.
- Bandgeführte Belastungseinheit. Das Band vibriert nur, wenn du den heutigen Bereich verlässt.
+ Zusammenfassung deines letzten Trainings anzeigen.
+ Strap-geführtes Training. Der Strap vibriert nur, wenn du den heutigen Bereich verlässt.
BETA
%1$s · letzte Nacht
Wähle die Karten für Heute. Karten ohne Wert zeigen einen Strich.
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 33665e3c15..718b7f1f2a 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -1488,7 +1488,6 @@
Informe de error
Arreglar
Organizar secciones Hoy
- Las líneas de base aprendieron en el dispositivo durante 14 días. Las barras son una lectura aproximada de cada
Oxígeno en sangre
Respiraciones por minuto
Calorías
@@ -2183,6 +2182,7 @@
Ciclo menstrual
Diario
Tarjetas añadidas
+ Las referencias se aprenden en el dispositivo durante 14 días. Cada barra muestra una estimación de una señal frente al intervalo típico de un adulto; no es consejo médico.
Hoy
24 h
12 h
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index 39cc61ae1e..10aba353ee 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -1506,7 +1506,6 @@
Rapport de bug
Régler
Organiser les sections d\'aujourd\'hui
- Données de référence apprises sur les appareils pendant 14 jours. Les barres sont une lecture approximative de chaque
Oxygène sanguin
Respirations par minute
Calories
@@ -2182,6 +2181,7 @@
Cycle menstruel
Journal de bord
Cartes ajoutées
+ Les valeurs de référence sont apprises sur l’appareil pendant 14 jours. Chaque barre donne une estimation d’un signal par rapport à la plage habituelle d’un adulte ; il ne s’agit pas d’un avis médical.
Aujourd’hui
24 h
12 h
diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml
index f9d52fcd8d..64ac24fdaf 100644
--- a/android/app/src/main/res/values-pl/strings.xml
+++ b/android/app/src/main/res/values-pl/strings.xml
@@ -1645,7 +1645,6 @@
Raport o błędzie
Zorganizować
Ułóż sekcje Dzisiaj
- Wartości bazowe uczyły się na urządzeniu w ciągu 14 dni. Słupki są przybliżonym odczytem każdego z nich
Tlen we krwi
Oddechy na minutę
Kalorie
@@ -2784,6 +2783,7 @@
Włączone: Twój ładunek, odpoczynek, HRV i treningi trafiają do dostawcy, każdy trening ze sportem, czasem, dystansem i tętnem.
Wyłączone: coach odpowiada ogólnie i nie wysyła żadnych Twoich danych.
Ekran Dzisiaj, który układasz sam, stres na ekranie głównym i kopie zapasowe, które same się sprawdzają
+ Wartości bazowe są wyznaczane na urządzeniu przez 14 dni. Każdy pasek przedstawia przybliżony odczyt jednego sygnału na tle typowego zakresu dla osoby dorosłej; nie stanowi to porady medycznej.
Ocenione do %1$s. Późniejsze godziny wymagają więcej pomiarów tętna.
Skanuj i połącz
Wiek sprawności zatrzymuje się tutaj. VO₂max nie.
diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml
index f893003b54..49b654ec04 100644
--- a/android/app/src/main/res/values-pt-rPT/strings.xml
+++ b/android/app/src/main/res/values-pt-rPT/strings.xml
@@ -1651,7 +1651,6 @@
Relatório de erro
Organizar
Organizar secções de Hoje
- Linhas de base aprendidas no dispositivo durante 14 dias. As barras são uma leitura aproximada de cada
Oxigénio no sangue
Respirações por minuto
Calorias
@@ -2175,6 +2174,7 @@
Ciclo menstrual
Diário
Cartões adicionados
+ Os valores de referência são aprendidos no dispositivo ao longo de 14 dias. Cada barra apresenta uma estimativa de um sinal face ao intervalo típico de um adulto; não constitui aconselhamento médico.
Hoje
24 h
12 h
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 7e80cfefe7..6ddf708f87 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -1706,7 +1706,6 @@
Отчёт об ошибке
Расположение
Настроить разделы «Сегодня»
- Нормы выучены на устройстве за 14 дней. Полосы дают приблизительное представление о каждом
Кислород в крови
Вдохов в минуту
Калории
@@ -2663,6 +2662,7 @@
Включено: заряд, отдых, ВСР и тренировки отправляются провайдеру, каждая тренировка с видом спорта, длительностью, дистанцией и пульсом.
Выключено: коуч отвечает в общем виде и не отправляет ваши показатели.
Экран «Сегодня», который вы составляете сами, стресс на главном экране и резервные копии, которые проверяют себя сами
+ Базовые значения определяются на устройстве в течение 14 дней. Каждая полоса приблизительно показывает один показатель относительно типичного диапазона для взрослого человека; это не медицинская рекомендация.
Оценено до %1$s. Для более поздних часов нужно больше измерений пульса.
Найти и подключить
Фитнес-возраст здесь останавливается. VO₂max — нет.
diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml
index 74a90157b1..2f1f43eb24 100644
--- a/android/app/src/main/res/values-zh/strings.xml
+++ b/android/app/src/main/res/values-zh/strings.xml
@@ -1627,7 +1627,6 @@
Bug 报告
整理
自定义“今日”面板排版
- 本地学习超过 14 天生成的个人基准线。条形图是一个相对粗略的范围参考...
血氧饱和度
次呼吸/分钟
卡路里
@@ -2687,6 +2686,7 @@
开启:你的充能、休息、HRV 和训练会发送给服务商,每次训练包含运动类型、时长、距离和心率。
关闭:教练只作一般性回答,不发送你的任何数据。
可自行编排的「今天」页面、主屏幕上的压力曲线,以及会自我校验的备份
+ 设备会在 14 天内学习基准值。每个条形图表示一项信号相对于典型成人范围的近似读数;这些信息不构成医疗建议。
已评分至 %1$s。较晚的时段需要更多心率采样。
扫描并连接
体能年龄到此为止,VO₂max 仍在变化。
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index b67e343ea0..23ea6c5d71 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1793,7 +1793,6 @@
Bug report
Arrange
Arrange Today sections
- Baselines learned on-device over 14 days. Bars are an approximate read of each
Blood Oxygen
Breaths per minute
Calories
@@ -2304,6 +2303,7 @@
Menstrual Cycle
Journal
Added Cards
+ Baselines are learned on-device over 14 days. Each bar is an approximate reading of one signal against a typical adult range, not medical advice.
Today
24h
12h
diff --git a/android/app/src/test/java/com/noop/ui/GermanLocalizationTest.kt b/android/app/src/test/java/com/noop/ui/GermanLocalizationTest.kt
index 663343fca5..27554b00cc 100644
--- a/android/app/src/test/java/com/noop/ui/GermanLocalizationTest.kt
+++ b/android/app/src/test/java/com/noop/ui/GermanLocalizationTest.kt
@@ -1,11 +1,13 @@
package com.noop.ui
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.xmlpull.v1.XmlPullParser
import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
/**
* #694 — smoke test for the German (de) string-resource pass. Parses the default English
@@ -59,6 +61,32 @@ class GermanLocalizationTest {
return out
}
+ private fun parseVariants(file: File): Map> {
+ val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
+ val out = linkedMapOf>()
+ val strings = document.getElementsByTagName("string")
+ for (index in 0 until strings.length) {
+ val element = strings.item(index)
+ val name = element.attributes.getNamedItem("name")?.nodeValue ?: continue
+ out[name] = mapOf("other" to element.textContent)
+ }
+ val plurals = document.getElementsByTagName("plurals")
+ for (index in 0 until plurals.length) {
+ val element = plurals.item(index)
+ val name = element.attributes.getNamedItem("name")?.nodeValue ?: continue
+ val variants = linkedMapOf()
+ val children = element.childNodes
+ for (childIndex in 0 until children.length) {
+ val child = children.item(childIndex)
+ if (child.nodeName != "item") continue
+ val quantity = child.attributes.getNamedItem("quantity")?.nodeValue ?: continue
+ variants[quantity] = child.textContent
+ }
+ out[name] = variants
+ }
+ return out
+ }
+
@Test
fun germanResourcesResolveForEveryLocalizedKey() {
val enFile = resFile("values/strings.xml")
@@ -108,4 +136,66 @@ class GermanLocalizationTest {
)
}
}
+
+ @Test
+ fun germanTodayVocabularyAndCompleteExplanationStayReviewed() {
+ val enFile = resFile("values/strings.xml")
+ val deFile = resFile("values-de/strings.xml")
+ assumeTrue("strings.xml not found, skipping", enFile != null && deFile != null)
+ val en = parseStrings(enFile!!)
+ val de = parseStrings(deFile!!)
+
+ val expected = mapOf(
+ "today_section_hero" to "Energie / Belastung / Erholung",
+ "today_section_synthesis" to "Zusammenfassung",
+ "today_section_key_metrics" to "Wichtige Messwerte",
+ "today_section_live_session" to "Training starten",
+ "l10n_today_screen_detailed_tiles_0801721b" to "Detaillierte Kacheln",
+ "l10n_today_screen_rest_b79e5f48" to "Erholung",
+ "l10n_today_screen_recovery_ea924f72" to "Erholung",
+ "today_recovery_carried" to "Erholung · %1\$s",
+ "today_card_coupled_subtitle" to "Erholung, Belastung und Schlaf auf einen Blick",
+ "today_pending_scores_body" to "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" to "Eine Trainingseinschätzung, unabhängig von deinem Energiewert.",
+ "l10n_today_screen_sync_chip_syncing_desc_92daf60c" to "Verlaufssynchronisierung des Straps läuft, %1\$s",
+ "l10n_today_screen_squarer_tiles_with_a_trend_graph_3c297dec" to "Größere Kacheln mit einem Trenddiagramm unter dem Balken.",
+ )
+ expected.forEach { (key, value) -> assertEquals("unexpected German Today copy for $key", value, de[key]) }
+
+ val explanation = de.getValue("today_recovery_vitals_explanation")
+ assertTrue(explanation.startsWith("Basiswerte werden über 14 Tage"))
+ assertTrue(explanation.endsWith("keine medizinische Beratung."))
+ assertEquals(
+ placeholders(en.getValue("today_recovery_vitals_explanation")),
+ placeholders(explanation),
+ )
+
+ val todaySource = resFile("../java/com/noop/ui/TodayScreen.kt")?.readText().orEmpty()
+ assertTrue("Today must use the complete localized explanation", "R.string.today_recovery_vitals_explanation" in todaySource)
+ assertFalse("English explanation fragment must not be composed in Kotlin", "signal against a typical adult range" in todaySource)
+ }
+
+ @Test
+ fun germanTodayPlaceholdersAndPluralsMatchEnglish() {
+ val enFile = resFile("values/strings.xml")
+ val deFile = resFile("values-de/strings.xml")
+ assumeTrue("strings.xml not found, skipping", enFile != null && deFile != null)
+ val en = parseVariants(enFile!!).filterKeys { it.startsWith("today_") }
+ val de = parseVariants(deFile!!)
+
+ en.forEach { (key, englishVariants) ->
+ val germanVariants = de[key] ?: error("German Today resource missing: $key")
+ assertEquals("plural quantities differ for $key", englishVariants.keys, germanVariants.keys)
+ englishVariants.forEach { (quantity, english) ->
+ assertEquals(
+ "placeholders differ for $key/$quantity",
+ placeholders(english),
+ placeholders(germanVariants.getValue(quantity)),
+ )
+ }
+ }
+ }
+
+ private fun placeholders(value: String): List =
+ Regex("%(?:\\d+\\$)?[sdf]").findAll(value).map { it.value.replace(Regex("\\d+\\$"), "") }.sorted().toList()
}