import {french} from './translations.js'; const normalize = value => value.replace(/\s+/g, ' ').trim(); export function translate(value, locale) { if (locale !== 'fr') return value; const source = normalize(String(value)); let result = french[source]; if (result === undefined) { const rules = [ [/^(account|habits|heartbeats|time|weights|workout|proxy|libraries)(\.)?$/, (_, name, dot) => (french[name] || name)+(dot || '')], [/^(account|habits|heartbeats|time|weights|workout|proxy|libraries) API$/, (_, name) => 'API · '+(french[name] || name)], [/^(account|habits|heartbeats|time|weights|workout|proxy|libraries)( · .+)$/, (_, name, suffix) => (french[name] || name)+suffix], [/^passkey (\d+) · (.+)$/, (_, n, transports) => 'clé d’accès '+n+' · '+transports.split(', ').map(value=>({internal:'appareil',hybrid:'autre appareil',usb:'USB',nfc:'NFC',ble:'Bluetooth'}[value]||value)).join(', ')], [/^copy this key now; it is shown once: (.+)$/, (_, token) => 'copiez cette clé maintenant ; elle ne sera affichée qu’une fois : '+token], [/^reconnect to use (.+) and sign in securely\.$/, (_, name) => `reconnectez-vous pour utiliser ${name} et vous connecter en toute sécurité.`], [/^(\d+) of (\d+) habits done\. keep showing up\.$/, (_, n, total) => `${n} habitudes sur ${total} accomplies. continuez ainsi.`], [/^(.+)\. a day at a time\.$/, (_, date) => `${date}. un jour à la fois.`], [/^remove “(.+)”\? past check-ins will be kept\.$/, (_, name) => `supprimer « ${name} » ? les accomplissements passés seront conservés.`], [/^(uncheck|complete|remove) (.+)$/, (_, action, name) => `${{uncheck:'décocher',complete:'accomplir',remove:'supprimer'}[action]} ${name}`], [/^move (.+) (up|down)$/, (_, name, direction) => `${direction === 'up' ? 'monter' : 'descendre'} ${name}`], [/^day boundary: (.+)$/, (_, zone) => `fuseau horaire : ${zone}`], [/^(\d+) seconds$/, (_, n) => `${n} secondes`], [/^(\d+) exercises$/, (_, n) => `${n} exercices`], [/^next: (.+)$/, (_, name) => `à suivre : ${french[name] || name}`], [/^(.+) (left|circuit)$/, (_, time, kind) => `${time} ${kind === 'left' ? 'restant' : 'circuit'}`], [/^(get ready for |begin |rest\. next: )(.+)$/, (_, phase, name) => `${phase === 'get ready for ' ? 'préparez-vous : ' : phase === 'begin ' ? 'commencez : ' : 'repos. à suivre : '}${french[name] || name}`], [/^(.+) movement demonstration$/, (_, name) => `démonstration : ${french[name] || name}`], [/^begin (.+)\.$/, (_, name) => `commencez : ${french[name] || name}.`], [/^(get ready\.|rest\.) (.+) in (\d+) seconds\.$/, (_, phase, name, n) => `${french[phase]} ${french[name] || name} dans ${n} secondes.`], [/^(\d+) heartbeat(s?) need attention$/, (_, n) => `${n} signaux nécessitent votre attention`], [/^(\d+) ping(s?) received$/, (_, n) => `${n} signaux reçus`], [/^(\d+(?:\.\d+)?) (seconds|minutes|hours|days)( grace)?$/, (_, n, unit, grace) => `${n} ${{seconds:'secondes',minutes:'minutes',hours:'heures',days:'jours'}[unit]}${grace ? ' de tolérance' : ''}`], [/^every (.+)$/, (_, duration) => `toutes les ${translate(duration, locale)}`], [/^last: (.+)$/, (_, time) => `dernier : ${translate(time, locale)}`], [/^(\d+)(s|m|h|d) ago$/, (_, n, unit) => `il y a ${n} ${{s:'s',m:'min',h:'h',d:'j'}[unit]}`], [/^due in (\d+)(s|m|h|d)$/, (_, n, unit) => `attendu dans ${n} ${{s:'s',m:'min',h:'h',d:'j'}[unit]}`], [/^delete “(.+)” and all of its history\?$/, (_, name) => `supprimer « ${name} » et tout son historique ?`], ]; for (const [pattern, render] of rules) if (pattern.test(source)) { result = source.replace(pattern, render); break; } } return result === undefined ? value : String(value).replace(/\S[\s\S]*\S|\S/, () => result); } export function installTranslations(browser) { const {document, dmPreferences: preferences} = browser; preferences.translate = value => translate(value, preferences.locale); const records = new WeakMap(); let originals={};try{originals=JSON.parse(document.getElementById('dm-translations')?.textContent||'{}');}catch{} // These regions are rendered by users or contain code, never interface copy. const excluded = 'script,style,pre,code,textarea,[translate="no"],[contenteditable],.habit-name,#identity,#userEmail,#codeEmail,.monitor h3,.monitor-top .desc,.incident b,.incident p,.token-item b'; const nativeWeights = document.querySelector('#app') && browser.location.hostname.startsWith('weights.'); function update(node, attribute) { const element = node.nodeType === 3 ? node.parentElement : node; if (!element || element.closest(excluded) || (nativeWeights && element.closest('#app'))) return; const value = attribute ? node.getAttribute(attribute) : node.nodeValue; if (!value?.trim()) return; let record = records.get(node); if (!record) records.set(node, record = new Map()); const key = attribute || 'text'; const prior = record.get(key); const source = prior?.output === value ? prior.source : (originals[value]||value); const output = translate(source, preferences.locale); record.set(key, {source, output}); if (output !== value) { if (attribute) node.setAttribute(attribute, output); else node.nodeValue = output; } } function visit(node) { if (node.nodeType === 3) { update(node); return; } if (node.nodeType !== 1 || node.matches(excluded)) return; for (const attribute of ['aria-label', 'title', 'placeholder']) if (node.hasAttribute(attribute)) update(node, attribute); for (const child of node.childNodes) visit(child); } const sync = () => { for (const control of document.querySelectorAll('[data-dm-language]')) control.value = preferences.language; for (const control of document.querySelectorAll('[data-dm-theme]')) control.value = preferences.theme; visit(document.body); const title = document.querySelector('title'); if (title) visit(title); }; document.addEventListener('change', event => { if (event.target.matches('[data-dm-language]')) preferences.setLanguage(event.target.value); if (event.target.matches('[data-dm-theme]')) preferences.setTheme(event.target.value); const key=event.target.matches('[data-dm-language]')?'language':event.target.matches('[data-dm-theme]')?'theme':null; if(key)fetch(document.body.dataset.dmProject==='account'?'/api/preferences':'/sso/preferences',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({[key]:event.target.value})}).catch(()=>{}); }); browser.addEventListener('dm-preferences-change', sync); const observer = new browser.MutationObserver(mutations => { // Disconnect while writing so translated values cannot retrigger the observer. observer.disconnect(); for (const mutation of mutations) { if (mutation.type === 'childList') for (const node of mutation.addedNodes) visit(node); else if (mutation.type === 'attributes') update(mutation.target, mutation.attributeName); else visit(mutation.target); } observe(); }); const observe = () => observer.observe(document.body, {subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:['aria-label','title','placeholder']}); sync(); observe(); } // Speech is assembled in one locale before choosing a voice. No DOM timing or // partial phrase replacement can leave an English exercise inside French speech. export function workoutSpeech(kind,{locale='en',exercise='',seconds=0,first=false}={}){ const name=locale==='fr'?(french[exercise]||exercise):exercise; if(locale==='fr'){ if(kind==='rest')return `${first?'préparez-vous.':'repos.'} ${name} dans ${seconds} seconde${seconds===1?'':'s'}.`; if(kind==='begin')return `commencez : ${name}.`; if(kind==='finish')return 'félicitations ! vous avez terminé votre séance. bravo !'; if(kind==='switch')return 'changez de côté.'; } if(kind==='rest')return `${first?'get ready.':'rest.'} ${name} in ${seconds} second${seconds===1?'':'s'}.`; if(kind==='begin')return `begin ${name}.`; if(kind==='finish')return 'congratulations! you completed your workout. great job!'; if(kind==='switch')return 'switch sides.'; return translate(kind,locale); }