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() }