/** * @file presentation.c * @brief Locale-independent French/English TUI presentation catalog. */ #include "trainlog/presentation.h" #include #include #include #include #include #include #include #include typedef struct TrainlogTranslation { const char *key; const char *fr; const char *en; } TrainlogTranslation; static TrainlogPresentationLanguage current_language = TRAINLOG_PRESENTATION_FRENCH; static bool presentation_float_conversion(char conversion) { return strchr("aAeEfFgG", conversion) != NULL; } static size_t presentation_conversion_end(const char *format, size_t start, bool *is_float) { size_t index = start + 1U; *is_float = false; if (format[index] == '%') return index + 1U; while (format[index] != '\0' && strchr("diouxXfFeEgGaAcspn", format[index]) == NULL) ++index; if (format[index] == '\0') return index; *is_float = presentation_float_conversion(format[index]); return index + 1U; } static int presentation_c_vsnprintf(char *output, size_t capacity, const char *format, va_list arguments) __attribute__((format(printf, 3, 0))); static int presentation_c_vsnprintf(char *output, size_t capacity, const char *format, va_list arguments) { locale_t c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); locale_t previous = (locale_t)0; int result; if (c_locale != (locale_t)0) previous = uselocale(c_locale); result = vsnprintf(output, capacity, format, arguments); if (c_locale != (locale_t)0) { (void)uselocale(previous); freelocale(c_locale); } return result; } static int presentation_vsnprintf(char *output, size_t capacity, const char *format, va_list arguments) __attribute__((format(printf, 3, 0))); static int presentation_vsnprintf(char *output, size_t capacity, const char *format, va_list arguments) { int result; va_list copy; /* WHY: setlocale is required for terminal text handling, but numeric * presentation is an application-language decision. * CONTRACT: formatting never changes the process locale or stored/input * values; English uses '.', French uses ',' even under a contrary host. * INVARIANT: every translated renderer crosses this one boundary. */ va_copy(copy, arguments); result = presentation_c_vsnprintf(NULL, 0U, format, copy); va_end(copy); if (result < 0 || output == NULL || capacity == 0U) return result; if (current_language != TRAINLOG_PRESENTATION_FRENCH) { va_copy(copy, arguments); (void)presentation_c_vsnprintf(output, capacity, format, copy); va_end(copy); return result; } { char *raw = malloc((size_t)result + 1U); char *marked_format; char *marked; size_t format_length = strlen(format); size_t float_count = 0U; size_t index; unsigned char begin = 1U, end = 2U; size_t out = 0U; bool in_float = false; if (raw == NULL) return -1; va_copy(copy, arguments); (void)presentation_c_vsnprintf(raw, (size_t)result + 1U, format, copy); va_end(copy); for (; begin < 32U; ++begin) { if (memchr(raw, (int)begin, (size_t)result) == NULL) break; } for (; end < 32U; ++end) { if (end != begin && memchr(raw, (int)end, (size_t)result) == NULL) break; } if (begin == 32U || end == 32U) { /* All control bytes are user data: retain exact bytes rather than * risking a broad decimal rewrite. */ (void)snprintf(output, capacity, "%s", raw); free(raw); return result; } for (index = 0U; index < format_length;) { bool is_float = false; if (format[index] != '%') { ++index; continue; } index = presentation_conversion_end(format, index, &is_float); if (is_float) ++float_count; } if (float_count == 0U) { (void)snprintf(output, capacity, "%s", raw); free(raw); return result; } marked_format = malloc(format_length + float_count * 2U + 1U); if (marked_format == NULL) { free(raw); return -1; } { size_t source = 0U, target = 0U; while (format[source] != '\0') { size_t directive_end; bool is_float = false; if (format[source] != '%') { marked_format[target++] = format[source++]; continue; } directive_end = presentation_conversion_end(format, source, &is_float); if (is_float) marked_format[target++] = (char)begin; while (source < directive_end) marked_format[target++] = format[source++]; if (is_float) marked_format[target++] = (char)end; } marked_format[target] = '\0'; } va_copy(copy, arguments); marked = malloc((size_t)result + float_count * 2U + 1U); if (marked == NULL) { va_end(copy); free(marked_format); free(raw); return -1; } #if defined(__clang__) #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wformat-nonliteral" #elif defined(__GNUC__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wformat-nonliteral" #endif (void)presentation_c_vsnprintf(marked, (size_t)result + float_count * 2U + 1U, marked_format, copy); #if defined(__clang__) #pragma clang diagnostic pop #elif defined(__GNUC__) #pragma GCC diagnostic pop #endif va_end(copy); free(marked_format); free(raw); for (index = 0U; marked[index] != '\0' && out + 1U < capacity; ++index) { if ((unsigned char)marked[index] == begin) { in_float = true; continue; } if ((unsigned char)marked[index] == end) { in_float = false; continue; } output[out++] = in_float && marked[index] == '.' ? ',' : marked[index]; } output[out] = '\0'; free(marked); } return result; } /* CONTRACT: keys are serialization-stable presentation identifiers. French is * the product default; English strings are embedded and never depend on an * installed OS locale or gettext catalog. */ static const TrainlogTranslation translations[] = { {"nav.home", "Accueil", "Home"}, {"nav.sessions", "Séances", "Sessions"}, {"nav.exercises", "Exercices", "Exercises"}, {"nav.statistics", "Statistiques", "Statistics"}, {"nav.sync", "Synchronisation", "Synchronization"}, {"nav.settings", "Paramètres", "Settings"}, {"route.session.current", "Séances / Séance en cours", "Sessions / Current session"}, {"route.session.generator", "Séances / Programmer", "Sessions / Plan"}, {"route.session.manual", "Séances / Nouvelle séance", "Sessions / New session"}, {"route.session.completed", "Séances / Effectuées", "Sessions / Completed"}, {"route.session.detail", "Séances / Détail", "Sessions / Detail"}, {"route.exercise.catalog", "Exercices / Catalogue", "Exercises / Catalog"}, {"route.exercise.detail", "Exercices / Fiche", "Exercises / Details"}, {"route.exercise.knowledge", "Exercices / Connaissances", "Exercises / Knowledge"}, {"route.exercise.performance", "Statistiques / Performance", "Statistics / Performance"}, {"route.exercise.max", "Statistiques / MAX mesuré", "Statistics / Measured MAX"}, {"route.equipment.catalog", "Équipements / Catalogue", "Equipment / Catalog"}, {"route.equipment.detail", "Équipements / Fiche", "Equipment / Details"}, {"route.stats.training", "Statistiques / Entraînement", "Statistics / Training"}, {"route.stats.exercise", "Statistiques / Par exercice", "Statistics / By exercise"}, {"route.stats.exercise.detail", "Statistiques / Exercice", "Statistics / Exercise"}, {"route.stats.zones", "Statistiques / Zones", "Statistics / Zones"}, {"route.body", "Statistiques / Mensurations", "Statistics / Body measurements"}, {"route.body.detail", "Mensurations / Relevé", "Body measurements / Observation"}, {"route.body.metric", "Mensurations / Historique", "Body measurements / History"}, {"route.body.trends", "Mensurations / 12 mois", "Body measurements / 12 months"}, {"route.body.global", "Mensurations / Vue globale", "Body measurements / Overview"}, {"route.body.analytics", "Mensurations / Analyse", "Body measurements / Analysis"}, {"route.max", "Statistiques / Capacités MAX", "Statistics / MAX capacities"}, {"settings.language", "Langue", "Language"}, {"settings.language.help", "Entrée pour changer de langue.", "Press Enter to change language."}, {"settings.language.save_failed", "Échec de l’enregistrement de la langue ; choix précédent conservé.", "Could not save language; previous choice kept."}, {"settings.language.durability_uncertain", "Langue enregistrée, mais sa durabilité après incident est incertaine.", "Language saved, but crash durability is uncertain."}, {"settings.body_profile", "Profil d’estimation corporelle", "Body-estimation profile"}, {"settings.body_profile.help", "Entrée pour consulter ou modifier le profil.", "Press Enter to view or edit the profile."}, {"language.fr", "Français", "Français"}, {"language.en", "English", "English"}, {"action.open", "Entrée Ouvrir", "Enter Open"}, {"action.back", "Échap Retour", "Esc Back"}, {"action.actions", "F7 Actions", "F7 Actions"}, {"action.navigation", "F6 Navigation", "F6 Navigation"}, {"action.help", "? Aide", "? Help"}, {"empty.no_data", "Aucune donnée disponible.", "No data available."}, {"chart.timeline", "ÉVOLUTION DANS LE TEMPS", "CHANGE OVER TIME"}, {"chart.totals", "TOTAUX PAR PÉRIODE", "TOTALS BY PERIOD"}, {"shell.too_small", "Terminal trop petit", "Terminal too small"}, {"shell.minimum", "Minimum : 72×20", "Minimum: 72×20"}, {"sync.confirm.both", "Synchroniser Android et PC dans les deux sens maintenant ?", "Synchronize Android and PC in both directions now?"}, {"sync.confirm.import", "Importer Android vers le PC maintenant ?", "Import from Android to PC now?"}, {"sync.confirm.publish", "Publier le catalogue du PC vers Android maintenant ?", "Publish the PC catalog to Android now?"}, {"sync.footer.wide", "s Synchroniser maintenant (PC↔Android) r Actualiser appareil Échap retour", "s Synchronize now (PC↔Android) r Refresh device Esc back"}, {"sync.footer.compact", "s Synchroniser PC↔Android r Actualiser Échap retour", "s Synchronize PC↔Android r Refresh Esc back"}, {"msg.body.prepare", "Impossible de préparer un identifiant ou une date de relevé.", "Unable to prepare an observation ID or date."}, {"msg.body.positive", "Enregistrement impossible : au moins une mesure positive est requise.", "Unable to save: at least one positive measurement is required."}, {"msg.body.corrected", "Relevé corrigé; identité, date et séance conservées.", "Observation corrected; identity, date and session preserved."}, {"msg.body.saved", "Relevé corporel enregistré.", "Body observation saved."}, {"msg.positive.invalid", "Nombre positif invalide; le texte saisi est conservé.", "Invalid positive number; entered text is preserved."}, {"msg.profile.formula", "Formule attendue : 1 homme ou 2 femme.", "Expected formula: 1 male or 2 female."}, {"msg.profile.height", "Taille attendue entre 100 et 250 cm; texte conservé.", "Expected height between 100 and 250 cm; text preserved."}, {"msg.profile.save.fail", "Impossible d’enregistrer le profil local; champs conservés.", "Unable to save the local profile; fields preserved."}, {"msg.profile.saved", "Profil d’estimation corporelle enregistré localement.", "Body-estimation profile saved locally."}, {"msg.catalog.read", "Impossible de lire le catalogue d’exercices.", "Unable to read the exercise catalog."}, {"msg.catalog.empty", "Ajoutez d’abord au moins un exercice au catalogue.", "First add at least one exercise to the catalog."}, {"msg.session.empty", "Ajoutez au moins un exercice avant d’enregistrer.", "Add at least one exercise before saving."}, {"msg.actual.required", "Saisissez un résultat réel pour %.120s.", "Enter an actual result for %.120s."}, {"msg.generation.rejected", "Génération refusée (%d).", "Generation rejected (%d)."}, {"msg.generation.empty", "Pas assez d’exercices résolus et compatibles; rien n’a été inventé.", "Not enough resolved compatible exercises; nothing was invented."}, {"msg.session.exists.preview", "Une séance est déjà en cours. Reprenez-la ou gardez cet aperçu.", "A session is already in progress. Resume it or keep this preview."}, {"msg.equipment.conflict", "Conflit de définition; les champs sont conservés.", "Definition conflict; fields are preserved."}, {"msg.equipment.save.fail", "Équipement non enregistré; tous les champs sont conservés.", "Equipment not saved; all fields are preserved."}, {"msg.equipment.invalid", "Équipement non créé : définition invalide ou conflit.", "Equipment not created: invalid or conflicting definition."}, {"msg.equipment.created", "Équipement personnel créé : %.120s", "Custom equipment created: %.120s"}, {"msg.field.required", "Ce champ est obligatoire; la saisie est conservée.", "This field is required; entered text is preserved."}, {"msg.zones.read", "Impossible de lire les zones actuelles.", "Unable to read current zones."}, {"msg.merge.targets", "Impossible de charger les cibles de fusion.", "Unable to load merge targets."}, {"msg.zone.required", "Une zone principale est requise pour un exercice par séries.", "A primary zone is required for a set-based exercise."}, {"msg.exercise.conflict", "Conflit de nom ou de profil; tous les champs sont conservés.", "Name or profile conflict; all fields are preserved."}, {"msg.exercise.save.fail", "Exercice non enregistré; tous les champs sont conservés.", "Exercise not saved; all fields are preserved."}, {"msg.exercise.reload", "Exercice créé, mais le sélecteur n’a pas pu être rechargé.", "Exercise created, but the picker could not be reloaded."}, {"msg.exercise.result", "Exercice %s : %.120s", "Exercise %s: %.120s"}, {"msg.exercise.edited", "modifié", "edited"}, {"msg.exercise.created", "créé", "created"}, {"msg.name.required", "Le nom est obligatoire; la saisie est conservée.", "The name is required; entered text is preserved."}, {"msg.route.active.field", "Terminez ou annulez le champ actif avant de changer de rubrique.", "Finish or cancel the active field before changing section."}, {"msg.merge.preview.fail", "Prévisualisation impossible; aucune fusion effectuée.", "Unable to preview; no merge performed."}, {"msg.merge.id.bound", "Fusion terminée; identifiant UI hors borne.", "Merge completed; UI identifier out of bounds."}, {"msg.merge.completed", "Fusion terminée. Cible sélectionnée : %.112s", "Merge completed. Selected target: %.112s"}, {"msg.merge.conflict", "Fusion refusée : profil ou zone principale incompatible. Aucun changement.", "Merge rejected: incompatible profile or primary zone. No change."}, {"msg.merge.fail", "Fusion impossible. Aucun changement confirmé.", "Merge failed. No change confirmed."}, {"msg.action.active.field", "Terminez ou annulez le champ actif avant une autre action.", "Finish or cancel the active field before another action."}, {"msg.equipment.display.required", "Le nom convivial est requis et doit tenir dans le champ.", "The display name is required and must fit in the field."}, {"msg.equipment.label.long", "Le nom d’étiquette est trop long.", "The label name is too long."}, {"msg.equipment.type.required", "Le type d’équipement est requis.", "Equipment type is required."}, {"msg.equipment.create.fail", "Équipement non créé : nom ou identifiant invalide/conflit.", "Equipment not created: invalid/conflicting name or identifier."}, {"msg.dose.rejected", "Dose refusée; la proposition précédente est conservée.", "Dose rejected; the previous proposal is preserved."}, {"msg.max.unavailable", "MAX compatible indisponible : exercice et équipement externe exacts requis.", "Compatible MAX unavailable: exact exercise and external equipment required."}, {"msg.value.invalid", "Valeur invalide; saisissez un nombre positif.", "Invalid value; enter a positive number."}, {"msg.percent.unavailable", "%%MAX indisponible : choisissez une résistance externe compatible.", "%%MAX unavailable: choose compatible external resistance."}, {"msg.field.not.applicable", "Ce champ ne s’applique pas au profil de cet exercice.", "This field does not apply to this exercise profile."}, {"msg.draft.save.fail", "Échec de l’enregistrement; le brouillon est conservé.", "Save failed; the draft is preserved."}, {"msg.session.exists", "Une séance est déjà en cours; elle n’a pas été remplacée.", "A session is already in progress; it was not replaced."}, {"sync.history.legacy", "Synchronisation historique PC↔Android\nHorodatage : %s\nÉtat : %s\nDétail hérité masqué (entrée historique)", "Historical PC↔Android synchronization\nTimestamp: %s\nStatus: %s\nLegacy detail hidden (historical entry)"}, {"sync.history.entry", "Synchronisation PC↔Android\nHorodatage : %s\nÉtat : %s\nDétail diagnostique conservé hors présentation", "PC↔Android synchronization\nTimestamp: %s\nStatus: %s\nDiagnostic detail retained outside presentation"}, {"sync.state.success", "réussie", "successful"}, {"sync.state.failure", "échouée", "failed"}, {"sync.result.success.counts", "Synchronisation réussie · %s · exercices +%zu, réconciliés %zu, présents %zu · séances +%zu, présentes %zu · relevés +%zu, présents %zu · équipements +%zu, présents %zu · catalogue publié %zu", "Synchronization successful · %s · exercises +%zu, reconciled %zu, present %zu · sessions +%zu, present %zu · observations +%zu, present %zu · equipment +%zu, present %zu · catalog published %zu"}, {"sync.result.failure.conflict", "Synchronisation refusée : une autre exécution est active.", "Synchronization rejected: another run is active."}, {"sync.result.failure.not_found", "Synchronisation impossible : aucun appareil MTP Trainlog détecté.", "Synchronization unavailable: no Trainlog MTP device detected."}, {"sync.result.failure.invalid", "Synchronisation refusée : données ou demande invalides.", "Synchronization rejected: invalid data or request."}, {"sync.result.failure.database", "Synchronisation interrompue : erreur de base de données locale.", "Synchronization stopped: local database error."}, {"sync.result.failure.version", "Synchronisation refusée : version de données non prise en charge.", "Synchronization rejected: unsupported data version."}, {"sync.result.failure.system", "Synchronisation interrompue : erreur d’accès appareil ou fichier.", "Synchronization stopped: device or file access error."}, {"sync.result.failure.no_report", "Synchronisation interrompue sans rapport de réussite.", "Synchronization stopped without a success report."}, {"summary.performance.none", "aucune série réussie", "no successful set"}, {"summary.assisted.duration", "%.1f kg aide × %s", "%.1f kg assistance × %s"}, {"summary.assisted.reps", "%.1f kg aide × %d reps", "%.1f kg assistance × %d reps"}, {"summary.continuous", "Continu · %s", "Continuous · %s"}, {"summary.plan.weight", "Plan %d×%d · %.2f kg · repos %d s · 0 réalisée", "Plan %d×%d · %.2f kg · rest %d s · 0 completed"}, {"summary.plan.no_load", "Plan %d×%d · charge absente · repos %d s · 0 réalisée", "Plan %d×%d · no load · rest %d s · 0 completed"}, {"summary.sets.prefix", "%zu séries · ", "%zu sets · "}, {"summary.assistance.suffix", " aide", " assistance"}, {"summary.waist.value", "taille %.1f", "waist %.1f"}, {"summary.waist.none", "taille —", "waist —"}, {"summary.chest.value", "poitrine %.1f", "chest %.1f"}, {"summary.chest.none", "poitrine —", "chest —"}, {"summary.body", "%-9s %-9s %-14s %2zu valeur(s)", "%-9s %-9s %-14s %2zu value(s)"}, {"chart.body.metric", "ÉVOLUTION DANS LE TEMPS · ←/→ %s", "CHANGE OVER TIME · ←/→ %s"}, {"chart.statistics", "←/→ %s · %s · %zu/5", "←/→ %s · %s · %zu/5"}, {"chart.exercise", "←/→ %s · %d/5", "←/→ %s · %d/5"}, {"list.exercise.count", "%zu exercice(s)", "%zu exercise(s)"}, {"list.occurrences", "%zu fois · %s", "%zu times · %s"}, {"value.not_applicable", "non applicable", "not applicable"}, {"goal.general", "Général", "General"}, {"goal.strength", "Force", "Strength"}, {"goal.hypertrophy", "Hypertrophie", "Hypertrophy"}, {"goal.endurance", "Endurance", "Endurance"}, {"dashboard.current", "actuel", "current"}, {"label.rest.seconds", "Repos (s)", "Rest (s)"}, {"label.duration", "durée", "duration"}, {"label.training.upper", "[ENTRAINEMENT]", "[TRAINING]"}, {"label.confidence.high", "élevée", "high"}, {"label.confidence.moderate", "modérée", "moderate"}, {"label.confidence.uncertain", "incertaine", "uncertain"}, {"ui.target.load.suffix", " · charge/assistance cible définie", " · target load/assistance defined"}, {"ui.training.choice", "%c Entraînement", "%c Training"}, {"ui.quit.lose", "%c Quitter et perdre cet état", "%c Quit and lose this state"}, {"ui.frequency.heading", "%s Fréquence", "%s Frequency"}, {"ui.catalog.distribution", "%s Répartition du catalogue", "%s Catalog distribution"}, {"ui.one.reading", "%s · %s %s · 1 relevé", "%s · %s %s · 1 reading"}, {"ui.one.reading.no.trend", "%s · %s %s · 1 relevé, tendance indisponible", "%s · %s %s · 1 reading, trend unavailable"}, {"ui.current.sessions", "%s · actuel %zu séance%s · MAX %zu", "%s · current %zu session%s · MAX %zu"}, {"ui.sessions.exercises", "%s · séances %zu · exercices distincts %zu", "%s · sessions %zu · distinct exercises %zu"}, {"ui.24h.zones", "24 h : %zu séries principales, %zu secondaires (%zu séances)", "24 h: %zu primary sets, %zu secondary (%zu sessions)"}, {"ui.72h.zones", "72 h : %zu séries principales, %zu secondaires (%zu séances)", "72 h: %zu primary sets, %zu secondary (%zu sessions)"}, {"ui.knowledge.relation", "> %.38s · confiance %s · sources vérifiées", "> %.38s · confidence %s · verified sources"}, {"ui.selected", "> [SÉLECTION]", "> [SELECTED]"}, {"ui.discard.definition", "Abandonner cette définition non enregistrée ?", "Discard this unsaved definition?"}, {"ui.discard.session", "Abandonner la séance en cours ?", "Discard the current session?"}, {"ui.discard.changes", "Abandonner les modifications non enregistrées ?", "Discard unsaved changes?"}, {"ui.continuous.no.fake", "Activité continue : %d s · aucune série fictive", "Continuous activity: %d s · no fake set"}, {"ui.assistance.measured", "Assistance mesurée (kg) — moins = mieux", "Measured assistance (kg) — lower is better"}, {"ui.associations", "Associations : %zu équipement(s), %zu zone(s).", "Associations: %zu equipment item(s), %zu zone(s)."}, {"ui.asym.forearms", "Asymétrie avant-bras", "Forearm asymmetry"}, {"ui.asym.arms", "Asymétrie bras", "Arm asymmetry"}, {"ui.asym.thighs", "Asymétrie cuisses", "Thigh asymmetry"}, {"ui.asym.calves", "Asymétrie mollets", "Calf asymmetry"}, {"ui.session.type.choose", "Choisissez le type de séance :", "Choose the session type:"}, {"ui.equipment.compatible", "Compatibles (manifeste) : %zu · utilisés historiquement : %zu", "Compatible (manifest): %zu · used historically: %zu"}, {"ui.knowledge.validated", "Connaissances validées", "Validated knowledge"}, {"ui.equipment.create.return", "Créer un équipement personnel — retour à l’occurrence", "Create custom equipment — return to occurrence"}, {"ui.equipment.create", "Créer un équipement personnel", "Create custom equipment"}, {"ui.create", "Créer", "Create"}, {"ui.last.days", "Dernière : J-%zu", "Latest: D-%zu"}, {"ui.relative.difference", "Différence relative : %+.1f%%", "Relative difference: %+.1f%%"}, {"ui.started", "Début : %s", "Started: %s"}, {"ui.detail", "Détail %s", "%s details"}, {"ui.estimate.notice", "Estimation anthropométrique : tendance, pas mesure directe.", "Anthropometric estimate: trend, not a direct measurement."}, {"ui.estimated.fat", "Graisse estimée", "Estimated fat"}, {"ui.id.preserved", "Identifiant stable conservé : %s", "Stable identifier preserved: %s"}, {"ui.feedback.count", "Immédiats %zu · J+1 séance %zu", "Immediate %zu · next-day session %zu"}, {"ui.merge.impact", "Impact source : %zu occurrence(s), %zu série(s), %zu continu, %zu MAX.", "Source impact: %zu occurrence(s), %zu set(s), %zu continuous, %zu MAX."}, {"ui.session.load.fail", "Impossible de charger la séance.", "Unable to load the session."}, {"ui.interpretation.conditional", "Interprétation conditionnelle — à confirmer", "Conditional interpretation — confirm"}, {"ui.interpretation.unresolved", "Interprétation scientifique non résolue.", "Scientific interpretation unresolved."}, {"ui.old.sessions.unchanged", "Les anciennes séances resteront inchangées.", "Existing sessions will remain unchanged."}, {"ui.series.partial", "Limite locale atteinte : certaines séries peuvent être partielles.", "Local limit reached: some series may be partial."}, {"ui.stats.max.feedback", "MAX %zu · ressentis immédiats %zu · prévu/réalisé séries %llu/%llu", "MAX %zu · immediate feedback %zu · planned/actual sets %llu/%llu"}, {"ui.max.measured.kg", "MAX mesuré (kg)", "Measured MAX (kg)"}, {"ui.max.actual", "MAX réellement mesuré : %.2f kg", "Actually measured MAX: %.2f kg"}, {"ui.fat.mass", "Masse grasse estimée", "Estimated fat mass"}, {"ui.lean.mass", "Masse maigre estimée", "Estimated lean mass"}, {"ui.best.set", "Meilleur set enregistré : %s", "Best recorded set: %s"}, {"ui.best.duration", "Meilleure durée", "Best duration"}, {"ui.best.repetitions", "Meilleures répétitions", "Best repetitions"}, {"ui.label.optional", "Nom d’étiquette (optionnel)", "Label name (optional)"}, {"ui.label.optional.short", "Nom étiquette (optionnel)", "Label name (optional)"}, {"ui.unclassified", "Non classés", "Unclassified"}, {"ui.not.entered", "Non renseignée", "Not entered"}, {"ui.new.observation", "Nouveau relevé", "New observation"}, {"ui.plan.heading", "Objectif prévu — séparé des valeurs réalisées", "Planned target — separate from actual values"}, {"ui.stats.occurrences", "Occurrences %zu · séances %zu · séries %zu · répétitions %llu", "Occurrences %zu · sessions %zu · sets %zu · repetitions %llu"}, {"ui.stats.linked", "Occurrences liées %zu · séries %llu/%llu", "Linked occurrences %zu · sets %llu/%llu"}, {"ui.body.current", "PROFIL ACTUEL · relevé %zu/%zu · %s", "CURRENT PROFILE · observation %zu/%zu · %s"}, {"ui.body.circumferences", "PROFIL CIRCONFÉRENCES · échelle au plus grand cm présent", "CIRCUMFERENCE PROFILE · scale to largest present cm"}, {"ui.body.page", "Page %zu/2 · relevé %s", "Page %zu/2 · observation %s"}, {"ui.plan.summary", "Plan : %d série(s) · repos %d s%s", "Plan: %d set(s) · rest %d s%s"}, {"ui.real.points", "Points réels du contexte :", "Actual context points:"}, {"ui.range.frequency", "Première %s · dernière %s · fréquence 7j %zu · 30j %zu", "First %s · latest %s · frequency 7d %zu · 30d %zu"}, {"ui.proposal.kept", "Proposition non acceptée conservée en mémoire.", "Unaccepted proposal kept in memory."}, {"ui.previous.difference", "Précédent %.1f kg · différence %+.1f kg%s", "Previous %.1f kg · difference %+.1f kg%s"}, {"ui.planned.actual.same", "Prévu/réalisé mêmes occurrences : %zu · séries %llu/%llu · reps %llu/%llu", "Planned/actual same occurrences: %zu · sets %llu/%llu · reps %llu/%llu"}, {"ui.period.selector", "Période %s %s %s %s %s", "Period %s %s %s %s %s"}, {"ui.period.selector3", "Période : %s · 1 7j · 2 30j · 3 Tout", "Period: %s · 1 7d · 2 30d · 3 All"}, {"ui.feedback.summary", "Ressentis : immédiats %zu · J+1 séance %zu · texte libre non analysé", "Feedback: immediate %zu · next-day session %zu · free text not analyzed"}, {"ui.feedback.frequent", "Ressentis immédiats %zu · plus fréquent %s (%zu) · J+1 séance %zu · texte libre non analysé", "Immediate feedback %zu · most frequent %s (%zu) · next-day session %zu · free text not analyzed"}, {"ui.remove.occurrence", "Retirer cette occurrence de la séance ?", "Remove this occurrence from the session?"}, {"ui.actual.sets.count", "Réalisé : %zu série(s)", "Actual: %zu set(s)"}, {"ui.actual.continuous", "Réalisé : activité continue · %s", "Actual: continuous activity · %s"}, {"ui.followup", "SUIVI APRÈS SÉANCE", "POST-SESSION FOLLOW-UP"}, {"ui.sync.busy.service", "Service de synchronisation occupé.", "Synchronization service busy."}, {"ui.max.tests", "Tests enregistrés : %zu · dernier %.1f kg · meilleur %.1f kg", "Recorded tests: %zu · latest %.1f kg · best %.1f kg"}, {"ui.free.text", "Texte libre non analysé", "Free text not analyzed"}, {"ui.period.all", "Tout · 12 périodes max.", "All · max 12 periods."}, {"ui.sync.one.run", "Une seule exécution peut être active.", "Only one run can be active."}, {"ui.fat.change", "Variation graisse estimée %+.2f point(s)", "Estimated fat change %+.2f point(s)"}, {"ui.volume.activity", "Volume %.1f kg · activité %s", "Volume %.1f kg · activity %s"}, {"ui.volume.na", "Volume chargé non applicable à ce profil.", "Loaded volume not applicable to this profile."}, {"ui.volume.valid", "Volume chargé valide : %.1f kg", "Valid loaded volume: %.1f kg"}, {"ui.history.bounded", "Vue bornée : historique récent partiel.", "Bounded view: recent history is partial."}, {"ui.create.action", "a Créer", "a Create"}, {"ui.max.filter", "a MAX mesurés/tous", "a Measured/all MAX"}, {"ui.auto.load", "charge automatique observée", "observed automatic load"}, {"ui.continuous.actions", "e durée · s vitesse · k distance", "e duration · s speed · k distance"}, {"ui.historical.entry", "entrée historique", "historical entry"}, {"ui.finish.edit", "f Terminer édition", "f Finish editing"}, {"ui.generate", "g Générer", "g Generate"}, {"ui.regenerate", "g Régénérer", "g Regenerate"}, {"ui.equipment.action", "i Équipement", "i Equipment"}, {"ui.movement.unclassified", "mouvement non classé", "unclassified movement"}, {"ui.exercise.create.action", "n Créer un exercice", "n Create exercise"}, {"ui.not.configured", "non configuré", "not configured"}, {"ui.plan.action", "p Objectif prévu", "p Planned target"}, {"ui.repetitions.lower", "répétitions", "repetitions"}, {"ui.no.equipment.lower", "sans équipement", "no equipment"}, {"ui.session.open", "séance ouverte", "open session"}, {"ui.sessions.lower", "séances", "sessions"}, {"ui.sets.lower", "séries", "sets"}, {"ui.generator.actions", "z zone · o objectif · t durée · g générer", "z zone · o goal · t duration · g generate"}, {"ui.body.no.metric", "ÉVOLUTION DANS LE TEMPS · aucune métrique disponible", "CHANGE OVER TIME · no metric available"}, {"ui.warmup.notice", "Échauffement et retour au calme absents en V1.", "Warm-up and cool-down absent in V1."}, {"ui.shoulders.waist", "Épaules / taille", "Shoulders / waist"}, {"ui.occurrence.equipment", "Équipement : %s (i pour la fiche)", "Equipment: %s (i for details)"}, {"ui.occurrence.equipment.heading", "Équipement de l’occurrence — ID stable de l’occurrence inchangé", "Occurrence equipment — stable occurrence ID unchanged"}, {"ui.device.not.refreshed", "État appareil non actualisé · r pour rechercher.", "Device status not refreshed · r to search."}, {"ui.label", "Étiquette : %s", "Label: %s"}, {"ui.to.enter", "à saisir · ", "to enter · "}, {"ui.more.circumferences", "… %zu autre(s) circonférence(s) · ↑↓ change le relevé", "… %zu other circumference(s) · ↑↓ changes observation"}, {"ui.previous.graph", "← Graphe précédent", "← Previous chart"}, {"ui.previous.measure", "← Mesure précédente", "← Previous measurement"}, {"ui.previous.metric", "← Métrique précédente", "← Previous metric"}, {"ui.next.metric", "→ Métrique suivante", "→ Next metric"}, {"ui.percent.available", " · pourcentage calculable", " · percentage available"}, {"ui.no.yes.discard", "%s Non %s Oui, abandonner", "%s No %s Yes, discard"}, {"ui.no.yes.delete", "%s Non %s Oui, supprimer", "%s No %s Yes, delete"}, {"ui.catalog.zones", "+ %zu zones dans le catalogue", "+ %zu zones in catalog"}, {"ui.no.zero", "0 Non", "0 No"}, {"ui.yes.edit", "1 Oui, modifier", "1 Yes, edit"}, {"ui.date", "Date : %s", "Date: %s"}, {"ui.latest.date", "Date du dernier : %s", "Latest date: %s"}, {"ui.name", "Nom : %s", "Name: %s"}, {"ui.display.name", "Nom convivial", "Display name"}, {"ui.display.name.alt", "Nom d’affichage", "Display name"}, {"ui.profile", "Profil : %s", "Profile: %s"}, {"ui.secondary", "Secondaires : %s", "Secondary: %s"}, {"ui.secondary.heading", "Secondaires :", "Secondary:"}, {"ui.sources", "Sources :", "Sources:"}, {"ui.tracking.organization", "Suivi : %s · organisation : %s", "Tracking: %s · organization: %s"}, {"ui.tracking", "Suivi : %s", "Tracking: %s"}, {"ui.all", "Tout", "All"}, {"ui.type", "Type : %s", "Type: %s"}, {"ui.type.heading", "Type", "Type"}, {"ui.all.selected", "[Tout]", "[All]"}, {"ui.profile.action", "p Profil estimation", "p Estimation profile"}, {"ui.percent.selected", "pourcentage MAX choisi", "selected MAX percentage"}, {"ui.merge.action", "u Fusionner avec…", "u Merge with…"}, {"ui.percent.help", "%% sur la charge : calcul utilisateur 1..100%% du dernier MAX compatible", "%% of load: user calculation 1..100%% of latest compatible MAX"}, {"ui.compatible.item", "%c %.48s [compatible]", "%c %.48s [compatible]"}, {"ui.feedback.exercise", "%s · %zu retour%s", "%s · %zu feedback item%s"}, {"ui.one.point", "%s · 1 point · tendance indisponible", "%s · 1 point · trend unavailable"}, {"ui.field.position", "%s — champ %zu/14", "%s — field %zu/14"}, {"ui.choose.exercise", "%s — choisir un exercice", "%s — choose an exercise"}, {"ui.change.measure", "%s — ←/→ change la mesure", "%s — ←/→ changes measurement"}, {"ui.zone.periods", "7j principal %zu · secondaire %zu · 30j principal %zu · secondaire %zu", "7d primary %zu · secondary %zu · 30d primary %zu · secondary %zu"}, {"ui.latest.primary", "Dernier principal : %s", "Latest primary: %s"}, {"ui.latest.secondary", "Dernier secondaire : %s", "Latest secondary: %s"}, {"ui.exercise.stats.fail", "Impossible de calculer les statistiques de cet exercice.", "Unable to calculate statistics for this exercise."}, {"ui.zone.stats.fail", "Impossible de calculer les statistiques par zone.", "Unable to calculate statistics by zone."}, {"ui.list.load.fail", "Impossible de charger cette liste.", "Unable to load this list."}, {"ui.history.read.fail", "Impossible de lire cet historique.", "Unable to read this history."}, {"ui.history.read.fail.alt", "Impossible de lire l’historique.", "Unable to read history."}, {"ui.history.partial", "Limite locale atteinte : historique partiel.", "Local limit reached: partial history."}, {"ui.max.compatible.none", "MAX compatible indisponible", "Compatible MAX unavailable"}, {"ui.distance.measure", "Mesurer la distance : %s · 0 non · 1 oui · ↑↓ choisir", "Measure distance: %s · 0 no · 1 yes · ↑↓ choose"}, {"ui.speed.measure", "Mesurer la vitesse : %s · 0 non · 1 oui · ↑↓ choisir", "Measure speed: %s · 0 no · 1 yes · ↑↓ choose"}, {"ui.view.filter", "Vue : %s · a afficher %s", "View: %s · a show %s"}, {"ui.none.lower", "aucune", "none"}, {"ui.load.ratio", "charge %.1f/%.1f kg", "load %.1f/%.1f kg"}, {"ui.continuous.lower", "continue", "continuous"}, {"ui.body.history.action", "m Historique mesure", "m Measurement history"}, {"ui.only.max", "seulement les MAX", "MAX only"}, {"ui.all.compatible", "tous les compatibles", "all compatible"}, {"ui.all.compatible.exercises", "tous les exercices compatibles", "all compatible exercises"}, {"ui.blank.clear", "vide conserve · - efface · valeur positive remplace", "blank keeps · - clears · positive value replaces"}, {"ui.blank.not.measured", "vide signifie mesure non faite · valeur positive ajoute", "blank means not measured · positive value adds"}, {"ui.no.load.action", "x Sans charge", "x No load"}, {"ui.external.lower", "externe", "external"}, }; static const TrainlogTranslation source_translations[] = { {"feedback.during.session", "Pendant la séance", "During session"}, /* COMPLETENESS INVARIANT: every active Trainlog-owned French renderer * literal/format in the TUI source directory has an exact FR->EN entry here or in * translations[]. The deterministic source audit in test_presentation * enforces this. Excluded source literals are only stable/internal keys, * user or catalog data, protocol values, units, or punctuation glyphs; * those must pass through byte-exact. */ {"audit.set.duration", "%.1f kg × %s", "%.1f kg × %s"}, {"audit.set.reps", "%.1f kg × %d reps", "%.1f kg × %d reps"}, {"audit.set.compact", "%s%s×%s%s", "%s%s×%s%s"}, {"audit.set.weight", "%s%s×%.2fkg%s", "%s%s×%.2fkg%s"}, {"audit.period.year", "1 an · pas de 31 j", "1 year · not 31 days"}, {"audit.chart.next", "→ Graphe suivant", "→ Next chart"}, {"audit.quit.discard", "q Abandonner", "q Discard"}, {"audit.generator.other.zone", "z Autre zone", "z Other zone"}, {"audit.generator.discard", "q Abandonner la proposition", "q Discard proposal"}, {"audit.measure.next", "→ Mesure suivante", "→ Next measurement"}, {"audit.occurrence.replace", "Remplacer l’occurrence", "Replace occurrence"}, {"audit.goal", "Objectif : %s", "Goal: %s"}, {"audit.generator.estimate", "%zu exercice(s) · cible %d min · estimation %d min", "%zu exercise(s) · target %d min · estimate %d min"}, {"audit.generator.short", "Proposition plus courte : aucun remplissage artificiel.", "Shorter proposal: no artificial filler."}, {"audit.plan.item", "%d×%d · repos %d s · %.30s", "%d×%d · rest %d s · %.30s"}, {"audit.plan.load", "%.2f kg · %s", "%.2f kg · %s"}, {"audit.exercise.article", "%s un exercice", "%s an exercise"}, {"audit.profile.future", "Les prochaines utilisations suivront le nouveau profil.", "Future uses will follow the new profile."}, {"audit.assistance", "Assistance (kg) — moins = mieux", "Assistance (kg) — lower is better"}, {"audit.body.read.fail", "Impossible de lire les mensurations.", "Unable to read body measurements."}, {"audit.body.discard", "Abandonner toutes les modifications ?", "Discard all changes?"}, {"audit.profile.discard", "Abandonner les modifications du profil ?", "Discard profile changes?"}, {"audit.profile.formula.choice", "Formule : 1 homme · 2 femme", "Formula: 1 male · 2 female"}, {"audit.dashboard.work", "%s · travail %zu · MAX %zu%s", "%s · work %zu · MAX %zu%s"}, {"audit.dashboard.work.simple", "%s · travail %zu · MAX %zu", "%s · work %zu · MAX %zu"}, {"audit.body.delta", "%s · %s %s · Δ %c%s", "%s · %s %s · Δ %c%s"}, {"audit.body.formula.female", "formule femme", "female formula"}, {"audit.body.formula.male", "formule homme", "male formula"}, {"audit.waist.trend", "Tour de taille %.1f cm · variation %+.1f cm", "Waist circumference %.1f cm · change %+.1f cm"}, {"audit.chest.waist", "Poitrine / taille", "Chest / waist"}, {"audit.sync.history", "Historique des synchronisations", "Synchronization history"}, {"audit.performance", "Performance — %s", "Performance — %s"}, {"audit.performance.context", "Contexte : %s · %s · %zu point%s", "Context: %s · %s · %zu point%s"}, {"audit.max.read.fail", "Impossible de lire les tests de MAX.", "Unable to read MAX tests."}, {"audit.max.history", "Historique des MAX", "MAX history"}, {"audit.search", "Recherche : %s%s", "Search: %s%s"}, {"audit.profile.summary", "%s · %.1f cm", "%s · %.1f cm"}, {"audit.merge.search", "Recherche cible : %s_", "Search target: %s_"}, {"audit.merge.source", "Fusionner la source : %.48s", "Merge source: %.48s"}, {"audit.merge.confirm", "Confirmer la fusion", "Confirm merge"}, {"audit.merge.source.heading", "SOURCE : %.54s", "SOURCE: %.54s"}, {"audit.merge.target.heading", " vers CANONIQUE : %.45s", " to CANONICAL: %.45s"}, {"audit.merge.cancel", "%c Annuler", "%c Cancel"}, {"audit.merge.commit", "%c Fusionner source vers canonique", "%c Merge source into canonical"}, {"audit.merge.result", "Résultat de la fusion", "Merge result"}, {"audit.merge.close", "Entrée fermer", "Enter close"}, {"legacy.home.tagline", "Votre entraînement, au même endroit.", "Your training, all in one place."}, {"legacy.home.session", "1 Reprendre ou commencer une séance", "1 Resume or start a session"}, {"legacy.home.body", "5 Ajouter ou consulter des mensurations", "5 Add or view body measurements"}, {"legacy.sessions.current", "Séance en cours", "Current session"}, {"legacy.sessions.new", "Nouvelle séance manuelle", "New manual session"}, {"legacy.sessions.completed", "Séances effectuées", "Completed sessions"}, {"legacy.selection", "▶ [SÉLECTION]", "▶ [SELECTED]"}, {"legacy.profile.none", "Profil non configuré.", "Profile not configured."}, {"legacy.navigation", "Navigation", "Navigation"}, {"legacy.actions", "Actions", "Actions"}, {"legacy.help", "Aide", "Help"}, {"legacy.footer.choose", "↑↓ choisir Entrée valider", "↑↓ choose Enter confirm"}, {"legacy.footer.close", "Échap fermer F7 Actions ? Aide", "Esc close F7 Actions ? Help"}, {"legacy.footer.global", "F6 Navigation F7 Actions ? Aide Échap Retour", "F6 Navigation F7 Actions ? Help Esc Back"}, {"legacy.search", "/ Rechercher", "/ Search"}, {"legacy.add", "a Ajouter", "a Add"}, {"legacy.edit", "e Modifier", "e Edit"}, {"legacy.create", "n Créer", "n Create"}, {"legacy.save", "Entrée Enregistrer", "Enter Save"}, {"legacy.next", "Entrée Champ suivant", "Enter Next field"}, {"legacy.cancel", "Échap Annuler", "Esc Cancel"}, {"legacy.return", "Entrée Retour", "Enter Back"}, {"legacy.resume", "0 Reprendre", "0 Resume"}, {"legacy.discard", "1 Abandonner", "1 Discard"}, {"legacy.close", "k Fermer", "k Close"}, {"legacy.refresh", "r Actualiser appareil", "r Refresh device"}, {"legacy.sync", "s Synchroniser maintenant", "s Synchronize now"}, {"legacy.position", "Position %zu/%zu", "Position %zu/%zu"}, {"legacy.position.partial", "Position %zu/%zu+ · limite locale atteinte", "Position %zu/%zu+ · local limit reached"}, {"legacy.partial", "Vue partielle : capacité locale de présentation atteinte.", "Partial view: local presentation capacity reached."}, {"legacy.compact", "Mode compact · F6 ouvre Navigation", "Compact mode · F6 opens Navigation"}, {"legacy.open.full", "Entrée ouvre la vue complète existante dans ce contexte.", "Enter opens the full view available in this context."}, {"legacy.periods", "%zu périodes · %.2f %s", "%zu periods · %.2f %s"}, {"legacy.readings", "◆ %zu relevés · %.2f %s", "◆ %zu readings · %.2f %s"}, {"legacy.training", "Entraînement", "Training"}, {"legacy.max_test", "Test de max", "Max test"}, {"legacy.supplied", "fourni", "supplied"}, {"legacy.custom", "personnalisé", "custom"}, {"legacy.unknown", "inconnu", "unknown"}, {"legacy.no_load", "sans charge", "no load"}, {"legacy.external_load", "charge externe", "external load"}, {"legacy.assistance", "assistance", "assistance"}, {"legacy.female", "Formule femme", "Female formula"}, {"legacy.male", "Formule homme", "Male formula"}, {"legacy.no_data", "Aucune donnée disponible.", "No data available."}, {"legacy.timeline", "ÉVOLUTION DANS LE TEMPS", "CHANGE OVER TIME"}, {"legacy.totals", "TOTAUX PAR PÉRIODE", "TOTALS BY PERIOD"}, {"legacy.quit", "q Quitter", "q Quit"}, {"legacy.esc.back", "Échap Retour", "Esc Back"}, {"legacy.profile.title", "Profil d’estimation corporelle", "Body-estimation profile"}, {"legacy.profile.help", "Entrée pour consulter ou modifier le profil.", "Press Enter to view or edit the profile."}, {"legacy.too.small", "Terminal trop petit — %dx%d, minimum 72x20.", "Terminal too small — %dx%d, minimum 72x20."}, {"legacy.quit.small", "q pour quitter", "q to quit"}, {"legacy.footer.global.format", "F6 Navigation F7 Actions ? Aide %s", "F6 Navigation F7 Actions ? Help %s"}, {"legacy.cancel.keep", "Annuler conserve l’état de cette exécution", "Cancel keeps this run's state"}, {"legacy.help.context", "Aide contextuelle", "Contextual help"}, {"legacy.help.home", "0/Home Accueil · 1/F1 Séance · 2/F2 Effectuées", "0/Home Home · 1/F1 Session · 2/F2 Completed"}, {"legacy.help.catalogs", "3/F3 Exercices · 4/F4 Équipements · 5/F5 Mensurations", "3/F3 Exercises · 4/F4 Equipment · 5/F5 Body measurements"}, {"legacy.help.sync", "6 Synchronisation · F6 Navigation · F7 Actions", "6 Synchronization · F6 Navigation · F7 Actions"}, {"legacy.help.focus", "Tab change le focus · Échap revient", "Tab changes focus · Esc goes back"}, {"legacy.session.alias", "1 Séance", "1 Session"}, {"legacy.completed.alias", "2 Effectuées", "2 Completed"}, {"legacy.exercises.alias", "3 Exercices", "3 Exercises"}, {"legacy.body.alias", "5 Mensurations", "5 Body measurements"}, {"legacy.sync.alias", "6 Synchronisation", "6 Synchronization"}, {"legacy.home.alias", "0 Accueil", "0 Home"}, {"legacy.enter.continue", "Entrée Continuer", "Enter Continue"}, {"legacy.esc.discard", "Échap Abandonner…", "Esc Discard…"}, {"legacy.enter.catalog", "Entrée Catalogue", "Enter Catalog"}, {"legacy.sets.result", "e Séries/résultat", "e Sets/result"}, {"legacy.save.action", "f Enregistrer", "f Save"}, {"legacy.add.set", "a Ajouter série", "a Add set"}, {"legacy.remove.set", "d Supprimer série", "d Remove set"}, {"legacy.speed.action", "s Vitesse", "s Speed"}, {"legacy.distance.action", "k Distance", "k Distance"}, {"legacy.dose.action", "e Modifier dose", "e Edit dose"}, {"legacy.load.auto", "u Charge auto", "u Auto load"}, {"legacy.body.12", "g Vue 12 mois", "g 12-month view"}, {"legacy.sync.action", "s Synchroniser maintenant · PC↔Android", "s Synchronize now · PC↔Android"}, {"legacy.stats.7", "7 jours · quotidien", "7 days · daily"}, {"legacy.stats.30", "30 jours · pas de 5 j", "30 days · 5-day steps"}, {"legacy.stats.90", "90 jours · pas de 15 j", "90 days · 15-day steps"}, {"legacy.period.week", "semaine courante", "current week"}, {"legacy.period.month", "mois courant", "current month"}, {"legacy.no.sessions.period", "Aucune séance dans cette période.", "No sessions in this period."}, {"legacy.stats.summary", "Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu", "Sessions %zu Sets %zu Exercises performed %zu MAX %zu"}, {"legacy.stats.read.error", "Certaines données n’ont pas pu être lues.", "Some data could not be read."}, {"legacy.stats.invalid", "Données historiques invalides ignorées.", "Invalid historical data ignored."}, {"legacy.stats.insufficient", "Données insuffisantes.", "Insufficient data."}, {"legacy.no.active.exercises", "Aucun exercice actif à répartir.", "No active exercises to distribute."}, {"legacy.stats.exercises", "Exercices · faits et courbes par identité", "Exercises · occurrences and charts by identity"}, {"legacy.stats.zones", "Zones · travail principal et secondaire", "Zones · primary and secondary work"}, {"legacy.stats.center", "Centre d’analyse · données enregistrées uniquement", "Analysis center · recorded data only"}, {"legacy.no.body", "Aucune mensuration.", "No body measurement."}, {"legacy.no.body.entered", "Aucune mensuration saisie.", "No body measurements entered."}, {"legacy.no.body.series", "Aucune série corporelle disponible.", "No body series available."}, {"legacy.body.trend", "Évolution corporelle normalisée — 12 mois", "Normalized body change — 12 months"}, {"legacy.month.failure", "Impossible de déterminer le mois courant.", "Unable to determine the current month."}, {"legacy.body.two.months", "Premières courbes après 2 mois relevés pour une même mesure.", "Charts start after 2 recorded months for the same measurement."}, {"legacy.body.partial", "Limite locale atteinte : vue 12 mois potentiellement partielle.", "Local limit reached: 12-month view may be partial."}, {"legacy.no.body.observation", "Aucun relevé corporel.", "No body observation."}, {"legacy.weight", "Poids", "Weight"}, {"legacy.height", "Taille", "Height"}, {"body.neck", "Cou", "Neck"}, {"body.shoulders", "Épaules", "Shoulders"}, {"body.chest", "Poitrine", "Chest"}, {"body.waist", "Tour de taille", "Waist"}, {"body.hips", "Hanches", "Hips"}, {"body.left_arm", "Bras gauche", "Left arm"}, {"body.right_arm", "Bras droit", "Right arm"}, {"body.left_forearm", "Avant-bras gauche", "Left forearm"}, {"body.right_forearm", "Avant-bras droit", "Right forearm"}, {"body.left_thigh", "Cuisse gauche", "Left thigh"}, {"body.right_thigh", "Cuisse droite", "Right thigh"}, {"body.left_calf", "Mollet gauche", "Left calf"}, {"body.right_calf", "Mollet droit", "Right calf"}, {"legacy.weight.value", "Poids : %.2f kg", "Weight: %.2f kg"}, {"legacy.weight.none", "Poids : non renseigné", "Weight: not entered"}, {"legacy.no.circumference", "Aucune circonférence dans ce relevé.", "No circumference in this observation."}, {"legacy.device", "Appareil : %s %s", "Device: %s %s"}, {"legacy.device.none", "Aucun appareil MTP Trainlog détecté.", "No Trainlog MTP device detected."}, {"legacy.sync.running", "Synchronisation en cours… PC↔Android", "Synchronization in progress… PC↔Android"}, {"legacy.sync.confirm.help", "Entrée confirme une exécution · Échap annule", "Enter confirms · Esc cancels"}, {"legacy.sync.failed", "Synchronisation échouée.", "Synchronization failed."}, {"legacy.sync.none", "Aucune synchronisation enregistrée.", "No synchronization recorded."}, {"legacy.no.points", "Aucun point exploitable pour ce contexte.", "No usable data points for this context."}, {"legacy.no.body.add", "Aucun relevé corporel. a pour ajouter un relevé.", "No body observation. Press a to add one."}, {"legacy.no.set", "Aucune série réelle. a pour saisir la première.", "No actual set. Press a to enter the first."}, {"legacy.no.occurrence", "Aucune occurrence. a pour choisir un exercice.", "No occurrence. Press a to choose an exercise."}, {"legacy.enter.continue.simple", "Entrée pour continuer", "Enter to continue"}, {"legacy.enter.back", "Entrée pour revenir", "Enter to go back"}, {"legacy.discard.resume", "1 abandonner · 0/Échap reprendre", "1 discard · 0/Esc resume"}, {"legacy.actions.available", "Actions disponibles", "Available actions"}, {"legacy.action.position", "Action %zu/%zu%s%s", "Action %zu/%zu%s%s"}, {"legacy.merge.none", "Aucune cible correspondante.", "No matching target."}, {"legacy.merge.preview", "Entrée prévisualiser · Échap annuler", "Enter preview · Esc cancel"}, {"legacy.merge.confirm", "Confirmer la fusion", "Confirm merge"}, {"legacy.stats.body", "Corps · poids et mensurations", "Body · weight and measurements"}, {"legacy.stats.training", "Entraînement · vue globale et régularité", "Training · overview and consistency"}, {"legacy.stats.max", "MAX · historique des tests enregistrés", "MAX · recorded test history"}, {"legacy.stats.open", "Entrée ouvrir · aucune recommandation ni score global", "Enter open · no recommendation or overall score"}, {"legacy.history.all", "Tout l’historique", "All history"}, {"legacy.period", "Période", "Period"}, {"legacy.volume", "Volume chargé", "Loaded volume"}, {"legacy.sessions", "Séances", "Sessions"}, {"legacy.sets", "Séries", "Sets"}, {"legacy.repetitions", "Répétitions", "Repetitions"}, {"legacy.feedback", "Ressentis immédiats", "Immediate feedback"}, {"legacy.feedback.unit", "retours", "feedback"}, {"legacy.calendar.weeks", "semaines calendaires", "calendar weeks"}, {"legacy.calendar.months", "mois calendaires", "calendar months"}, {"legacy.history.months", "mois · historique complet", "months · full history"}, {"legacy.history.weeks", "semaines · historique complet", "weeks · full history"}, {"legacy.stats.failure", "Impossible de calculer les statistiques enregistrées.", "Unable to calculate recorded statistics."}, {"legacy.activity", "ACTIVITÉ", "ACTIVITY"}, {"legacy.work", "TRAVAIL", "WORK"}, {"legacy.feedback.heading", "RESSENTIS", "FEEDBACK"}, {"legacy.planned.actual", "PRÉVU / RÉALISÉ", "PLANNED / ACTUAL"}, {"legacy.observed.load", "Charge observée", "Observed load"}, {"legacy.max.recorded", "MAX enregistrés", "Recorded MAX"}, {"legacy.measure", "Mesure", "Measurement"}, {"ui.insufficient.suffix", " · données insuffisantes", " · insufficient data"}, {"ui.cancel.choice", "%c Annuler", "%c Cancel"}, {"ui.no.equipment", "%c Aucun équipement", "%c No equipment"}, {"ui.body.heading", "%s Mensurations", "%s Body measurements"}, {"ui.no.yes.edit", "0/Échap/q Non · 1 Oui, modifier", "0/Esc/q No · 1 Yes, edit"}, {"ui.add.occurrence", "Ajouter une occurrence", "Add an occurrence"}, {"ui.no.max", "Aucun MAX enregistré.", "No recorded MAX."}, {"ui.no.session.exercise", "Aucun exercice dans cette séance.", "No exercise in this session."}, {"ui.no.result", "Aucun résultat.", "No result."}, {"ui.none", "Aucun", "None"}, {"ui.no.verified.anatomy", "Aucune association vérifiée; aucune anatomie déduite du nom.", "No verified association; no anatomy inferred from the name."}, {"ui.no.numeric.load", "Aucune charge numérique qualifiée", "No qualified numeric load"}, {"ui.no.science", "Aucune fiche scientifique pour cet identifiant.", "No scientific record for this identifier."}, {"ui.no.zone.history", "Aucune zone associée à un entraînement enregistré.", "No zone associated with recorded training."}, {"ui.no.feminine", "Aucune", "None"}, {"ui.load", "Charge : %s", "Load: %s"}, {"ui.load.help", "Charge : 1 aucune · 2 externe · 3 assistance", "Load: 1 none · 2 external · 3 assistance"}, {"ui.best.set.load", "Charge du meilleur set (kg)", "Best-set load (kg)"}, {"ui.load.summary", "Charge max %.1f kg · volume total %.1f kg · moyen/occ. %.1f kg · /séance %.1f kg", "Max load %.1f kg · total volume %.1f kg · avg/occ. %.1f kg · /session %.1f kg"}, {"ui.target.assistance", "Charge/assistance cible (kg)", "Target load/assistance (kg)"}, {"ui.distance", "Distance : %.2f km", "Distance: %.2f km"}, {"ui.distance.state", "Distance : %s%.2f km", "Distance: %s%.2f km"}, {"ui.duration.minutes", "Durée : %d min", "Duration: %d min"}, {"ui.max.duration", "Durée MAX mesurée", "Measured MAX duration"}, {"ui.target.duration", "Durée cible (s)", "Target duration (s)"}, {"ui.duration.recorded", "Durée enregistrée : %llu s · volume chargé non applicable", "Recorded duration: %llu s · loaded volume not applicable"}, {"ui.average.duration.none", "Durée moyenne : indisponible (fin de séance absente)", "Average duration: unavailable (session end missing)"}, {"ui.average.duration.unavailable", "Durée moyenne indisponible", "Average duration unavailable"}, {"ui.session.duration", "Durée séances horodatées %s · moyenne %.0f min (%zu séance%s)", "Timed sessions duration %s · average %.0f min (%zu session%s)"}, {"ui.equipment.choose", "Entrée choisir · x aucun · n créer puis reprendre (phase catalogue)", "Enter choose · x none · n create then resume (catalog phase)"}, {"ui.keep.proposal", "Entrée conserver et revenir · d Abandonner la proposition", "Enter keep and return · d Discard proposal"}, {"ui.enter.close", "Entrée fermer", "Enter close"}, {"ui.exercise.actions", "Entrée fiche équipement · p Performances · m MAX · k Connaissances · e Modifier", "Enter equipment details · p Performance · m MAX · k Knowledge · e Edit"}, {"ui.back.catalog", "Entrée pour revenir au catalogue", "Enter to return to catalog"}, {"ui.exercise.result", "Exercice %s : %.120s", "Exercise %s: %.120s"}, {"ui.exercise.position", "Exercice %zu/%zu — %s", "Exercise %zu/%zu — %s"}, {"ui.exercise.reload.fail", "Exercice créé, mais le sélecteur n’a pas pu être rechargé.", "Exercise created, but the picker could not be reloaded."}, {"ui.exercise.save.fail", "Exercice non enregistré; tous les champs sont conservés.", "Exercise not saved; all fields are preserved."}, {"ui.exercise.recency", "Exercices %zu · occurrences %zu · dernière J-%zu", "Exercises %zu · occurrences %zu · latest D-%zu"}, {"ui.exercise.none", "Exercices 0 · occurrences 0 · aucune séance", "Exercises 0 · occurrences 0 · no session"}, {"ui.exercise.possible", "Exercices possibles", "Possible exercises"}, {"ui.merge.fail", "Fusion impossible. Aucun changement confirmé.", "Merge failed. No change confirmed."}, {"ui.merge.conflict", "Fusion refusée : profil ou zone principale incompatible. Aucun changement.", "Merge rejected: incompatible profile or primary zone. No change."}, {"ui.quit.warning", "Le brouillon et les propositions gardés seulement pour ce run seront perdus.", "The draft and proposals kept only for this run will be lost."}, {"ui.tracking.edit", "Modifier le mode de suivi ?", "Edit tracking mode?"}, {"ui.observation.edit", "Modifier le relevé", "Edit observation"}, {"ui.edit", "Modifier", "Edit"}, {"ui.metadata.partial", "Métadonnées de zones/usage partiellement indisponibles.", "Zone/usage metadata partially unavailable."}, {"ui.recording.help", "Organisation : 1 séries · 2 continu · ↑↓ choisir · Entrée continuer", "Organization: 1 sets · 2 continuous · ↑↓ choose · Enter continue"}, {"ui.proposal.settings", "Paramètres de la proposition", "Proposal settings"}, {"ui.weight.delta", "Poids %.1f kg · variation %+.1f kg", "Weight %.1f kg · change %+.1f kg"}, {"ui.repetitions.ratio", "Répétitions %llu/%llu", "Repetitions %llu/%llu"}, {"ui.max.repetitions", "Répétitions MAX mesurées", "Measured MAX repetitions"}, {"ui.target.repetitions", "Répétitions cibles", "Target repetitions"}, {"ui.merge.result", "Résultat de la fusion", "Merge result"}, {"ui.tracking.help", "Suivi : 1 répétitions · 2 durée · ↑↓ choisir · Entrée continuer", "Tracking: 1 repetitions · 2 duration · ↑↓ choose · Enter continue"}, {"ui.set.delete", "Supprimer cette série ?", "Delete this set?"}, {"ui.sync.device.none", "Synchronisation impossible : aucun appareil MTP Trainlog détecté.", "Synchronization unavailable: no Trainlog MTP device detected."}, {"ui.sync.database", "Synchronisation interrompue : erreur de base de données locale.", "Synchronization stopped: local database error."}, {"ui.sync.io", "Synchronisation interrompue : erreur d’accès appareil ou fichier.", "Synchronization stopped: device or file access error."}, {"ui.sync.no.report", "Synchronisation interrompue sans rapport de réussite.", "Synchronization stopped without a success report."}, {"ui.sync.invalid", "Synchronisation refusée : données ou demande invalides.", "Synchronization rejected: invalid data or request."}, {"ui.sync.busy", "Synchronisation refusée : une autre exécution est active.", "Synchronization rejected: another run is active."}, {"ui.sync.version", "Synchronisation refusée : version de données non prise en charge.", "Synchronization rejected: unsupported data version."}, {"ui.linked.session", "Séance liée : %s", "Linked session: %s"}, {"ui.sessions.full", "Séances %zu · exercices %zu · occurrences %zu · jours %zu · semaines %zu", "Sessions %zu · exercises %zu · occurrences %zu · days %zu · weeks %zu"}, {"ui.sessions.days", "Séances %zu · jours %zu · semaines %zu", "Sessions %zu · days %zu · weeks %zu"}, {"ui.sessions.recorded", "Séances enregistrées : %zu", "Recorded sessions: %zu"}, {"ui.sessions.timed", "Séances horodatées %s · moyenne %.0f min", "Timed sessions %s · average %.0f min"}, {"ui.sets.activity", "Séries %zu · répétitions %llu · durée d’activité %s", "Sets %zu · repetitions %llu · activity duration %s"}, {"ui.sets.repetitions", "Séries %zu · répétitions %llu", "Sets %zu · repetitions %llu"}, {"ui.target.sets", "Séries cibles", "Target sets"}, {"ui.actual.sets", "Séries réalisées — cibles séparées : %d×%d, repos %d s", "Completed sets — separate targets: %d×%d, rest %d s"}, {"ui.waist.hip", "Taille / hanches", "Waist / hips"}, {"ui.height.value", "Taille : %.1f cm", "Height: %.1f cm"}, {"ui.height.range.error", "Taille attendue entre 100 et 250 cm; texte conservé.", "Expected height between 100 and 250 cm; text preserved."}, {"ui.height.range", "Taille en cm : 100 à 250", "Height in cm: 100 to 250"}, {"ui.speed", "Vitesse : %.1f km/h", "Speed: %.1f km/h"}, {"ui.speed.state", "Vitesse : %s%.2f km/h", "Speed: %s%.2f km/h"}, {"ui.zone", "Zone : %s", "Zone: %s"}, {"ui.primary.secondary", "Zone principale : %s · secondaires : %zu", "Primary zone: %s · secondary: %zu"}, {"ui.primary", "Zone principale : %s", "Primary zone: %s"}, {"ui.recent.zone", "Zone travaillée récemment", "Recently trained zone"}, {"ui.scientific.zones", "Zones scientifiques :", "Scientific zones:"}, {"ui.secondary.zones", "Zones secondaires :", "Secondary zones:"}, {"ui.no.data.lower", "aucune donnée", "no data"}, {"ui.warning.help", "c/Entrée continuer · z changer de zone · Échap conserver et revenir", "c/Enter continue · z change zone · Esc keep and return"}, {"ui.zone.help", "p principale · Espace secondaire · n effacer · Entrée enregistrer", "p primary · Space secondary · n clear · Enter save"}, {"ui.save.draft.fail", "Échec de l’enregistrement; le brouillon est conservé.", "Save failed; the draft is preserved."}, {"ui.previous.zone", "↑ Zone précédente", "↑ Previous zone"}, {"ui.zone.bars", "↑/↓ zone · barres = séances distinctes sur 30 jours", "↑/↓ zone · bars = distinct sessions over 30 days"}, {"ui.choose.save", "↑↓ ou 1–3 choisir · Entrée enregistrer", "↑↓ or 1–3 choose · Enter save"}, {"ui.region.help", "↑↓ région · Entrée détail · chiffres période", "↑↓ region · Enter details · number selects period"}, {"ui.next.zone", "↓ Zone suivante", "↓ Next zone"}, {"ui.sub.exercise", "· Exercice", "· Exercise"}, {"ui.sub.body", "· Mensurations", "· Body measurements"}, {"ui.sub.current", "· Séance en cours", "· Current session"}, {"ui.sub.zones", "· Zones", "· Zones"}, {"ui.sub.body.trend", "· Évolution 12 mois", "· 12-month change"}, {"ui.sub.plan", "· Programmer", "· Plan"}, {"ui.sub.new.session", "· Nouvelle séance", "· New session"}, {"ui.sub.completed", "· Effectuées", "· Completed"}, {"ui.sub.session.detail", "· Détail séance", "· Session details"}, {"ui.sub.exercise.detail", "· Fiche exercice", "· Exercise details"}, {"ui.sub.knowledge", "· Connaissances", "· Knowledge"}, {"ui.sub.performance", "· Performances", "· Performance"}, {"ui.sub.exercise.max", "· MAX exercice", "· Exercise MAX"}, {"ui.sub.equipment", "· Fiche équipement", "· Equipment details"}, {"ui.sub.by.exercise", "· Par exercice", "· By exercise"}, {"ui.sub.overview", "· Vue globale", "· Overview"}, {"ui.sub.observation", "· Relevé corporel", "· Body observation"}, {"ui.sub.metric.history", "· Historique mesure", "· Measurement history"}, {"ui.sub.body.analysis", "· Analyse corporelle", "· Body analysis"}, {"ui.sub.capacities", "· Capacités / MAX", "· Capacities / MAX"}, }; static const char *translated(const TrainlogTranslation *items, size_t count, const char *value, bool by_key) { size_t index; if (value == NULL) return by_key ? "[missing translation key]" : ""; for (index = 0U; index < count; ++index) { const char *candidate = by_key ? items[index].key : items[index].fr; if (strcmp(candidate, value) == 0) return current_language == TRAINLOG_PRESENTATION_ENGLISH ? items[index].en : items[index].fr; } return by_key ? value : value; } void trainlog_presentation_init(void) { current_language = TRAINLOG_PRESENTATION_FRENCH; } TrainlogPresentationLanguage trainlog_presentation_language(void) { return current_language; } bool trainlog_presentation_set_language(TrainlogPresentationLanguage language) { if (language != TRAINLOG_PRESENTATION_FRENCH && language != TRAINLOG_PRESENTATION_ENGLISH) return false; current_language = language; return true; } const char *trainlog_presentation_language_code( TrainlogPresentationLanguage language) { return language == TRAINLOG_PRESENTATION_ENGLISH ? "en" : "fr"; } const char *trainlog_presentation_language_name( TrainlogPresentationLanguage language) { return trainlog_presentation_text(language == TRAINLOG_PRESENTATION_ENGLISH ? "language.en" : "language.fr"); } const char *trainlog_presentation_text(const char *key) { return translated(translations, sizeof(translations) / sizeof(translations[0]), key, true); } const char *trainlog_presentation_source(const char *source) { const char *result = translated(translations, sizeof(translations) / sizeof(translations[0]), source, false); if (result != source || source == NULL) return result; return translated(source_translations, sizeof(source_translations) / sizeof(source_translations[0]), source, false); } const char *trainlog_presentation_month_label(const char *source, char *output, size_t output_size) { static const char *const french[] = {"janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."}; static const char *const english[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; size_t index; if (source == NULL || output == NULL || output_size == 0U || current_language != TRAINLOG_PRESENTATION_ENGLISH) return source; for (index = 0U; index < 12U; ++index) { size_t prefix = strlen(french[index]); if (strncmp(source, french[index], prefix) == 0 && source[prefix] == ' ') { (void)snprintf(output, output_size, "%s%s", english[index], source + prefix); return output; } } return source; } int trainlog_presentation_vformat(char *output, size_t capacity, const char *source, va_list arguments) { const char *format = trainlog_presentation_source(source); if (output == NULL && capacity > 0U) return -1; /* The catalog is compiled into this translation unit and its placeholder * parity is covered by tests. Keep nonliteral formatting contained at this * one presentation boundary without weakening project warning flags. */ #if defined(__clang__) #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wformat-nonliteral" #elif defined(__GNUC__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wformat-nonliteral" #endif { int result = presentation_vsnprintf(output, capacity, format, arguments); #if defined(__clang__) #pragma clang diagnostic pop #elif defined(__GNUC__) #pragma GCC diagnostic pop #endif return result; } } int trainlog_presentation_format(char *output, size_t capacity, const char *source, ...) { va_list arguments; int result; va_start(arguments, source); result = trainlog_presentation_vformat(output, capacity, source, arguments); va_end(arguments); return result; } int trainlog_presentation_format_input(char *output, size_t capacity, const char *format, ...) { va_list arguments; locale_t c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); locale_t previous = (locale_t)0; int result; /* INVARIANT: changing presentation language never rewrites editable * numeric syntax, stored values, or exchange bytes. */ if (c_locale != (locale_t)0) previous = uselocale(c_locale); va_start(arguments, format); result = vsnprintf(output, capacity, format, arguments); va_end(arguments); if (c_locale != (locale_t)0) { (void)uselocale(previous); freelocale(c_locale); } return result; } int trainlog_presentation_format_key(char *output, size_t capacity, const char *key, ...) { va_list arguments; int result; const char *format = trainlog_presentation_text(key); va_start(arguments, key); #if defined(__clang__) #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wformat-nonliteral" #elif defined(__GNUC__) #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wformat-nonliteral" #endif result = presentation_vsnprintf(output, capacity, format, arguments); #if defined(__clang__) #pragma clang diagnostic pop #elif defined(__GNUC__) #pragma GCC diagnostic pop #endif va_end(arguments); return result; } bool trainlog_presentation_compact_date(const char *iso_date, bool with_year, char *output, size_t capacity) { int written; char first[3]; char second[3]; if (iso_date == NULL || output == NULL || strlen(iso_date) < 10U || iso_date[4] != '-' || iso_date[7] != '-') return false; if (current_language == TRAINLOG_PRESENTATION_FRENCH) { first[0] = iso_date[8]; first[1] = iso_date[9]; second[0] = iso_date[5]; second[1] = iso_date[6]; } else { first[0] = iso_date[5]; first[1] = iso_date[6]; second[0] = iso_date[8]; second[1] = iso_date[9]; } first[2] = '\0'; second[2] = '\0'; written = with_year ? snprintf(output, capacity, "%s/%s/%c%c", first, second, iso_date[2], iso_date[3]) : snprintf(output, capacity, "%s/%s", first, second); return written >= 0 && (size_t)written < capacity; } bool trainlog_presentation_config_path(char *output, size_t capacity) { const char *base = getenv("XDG_CONFIG_HOME"); const char *home; int written; if (output == NULL || capacity == 0U) return false; if (base != NULL && base[0] != '\0') written = snprintf(output, capacity, "%s/trainlog/presentation.conf", base); else { home = getenv("HOME"); if (home == NULL || home[0] == '\0') return false; written = snprintf(output, capacity, "%s/.config/trainlog/presentation.conf", home); } return written >= 0 && (size_t)written < capacity; } static bool ensure_parent_directories(const char *path) { char copy[4096]; char *slash; char *cursor; if (path == NULL || strlen(path) >= sizeof(copy)) return false; (void)snprintf(copy, sizeof(copy), "%s", path); slash = strrchr(copy, '/'); if (slash == NULL) return false; *slash = '\0'; for (cursor = copy + 1; *cursor != '\0'; ++cursor) { if (*cursor != '/') continue; *cursor = '\0'; if (mkdir(copy, 0700) != 0 && errno != EEXIST) return false; *cursor = '/'; } return mkdir(copy, 0700) == 0 || errno == EEXIST; } static bool sync_parent_directory(const char *path) { char parent[4096]; char *slash; int descriptor; bool ok; if (path == NULL || strlen(path) >= sizeof(parent)) return false; (void)snprintf(parent, sizeof(parent), "%s", path); slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0'; descriptor = open(parent, O_RDONLY | O_DIRECTORY); if (descriptor < 0) return false; ok = fsync(descriptor) == 0; if (close(descriptor) != 0) ok = false; return ok; } bool trainlog_presentation_load(void) { char path[4096]; char buffer[128]; FILE *file; size_t length; trainlog_presentation_init(); if (!trainlog_presentation_config_path(path, sizeof(path))) return false; file = fopen(path, "rb"); if (file == NULL) return errno == ENOENT; length = fread(buffer, 1U, sizeof(buffer) - 1U, file); if (ferror(file) != 0 || fclose(file) != 0) return false; buffer[length] = '\0'; if (strcmp(buffer, "version=1\nlanguage=fr\n") == 0) return true; if (strcmp(buffer, "version=1\nlanguage=en\n") == 0) { current_language = TRAINLOG_PRESENTATION_ENGLISH; return true; } return false; } TrainlogPresentationSaveResult trainlog_presentation_save(void) { char path[4096]; char temporary[4128]; const char *code = trainlog_presentation_language_code(current_language); int descriptor; int length; ssize_t written; bool file_ok; if (!trainlog_presentation_config_path(path, sizeof(path)) || !ensure_parent_directories(path)) return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; length = snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path); if (length < 0 || (size_t)length >= sizeof(temporary)) return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; /* WHY: a crashed prior process may leave a temporary file behind. * CONTRACT: mkstemp provides collision-safe same-directory creation; * mode 0600, file fsync and atomic rename remain mandatory. * INVARIANT: failure never replaces the last valid preference. */ descriptor = mkstemp(temporary); if (descriptor < 0) return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; if (fchmod(descriptor, 0600) != 0) { (void)close(descriptor); (void)unlink(temporary); return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; } length = snprintf(path, sizeof(path), "version=1\nlanguage=%s\n", code); written = write(descriptor, path, (size_t)length); /* CONTRACT: close executes exactly once on every post-creation path. * Short-write, fsync and close failures are aggregated; unlink follows * closure, and no failure before rename changes the prior preference. */ file_ok = written == length; if (file_ok && fsync(descriptor) != 0) file_ok = false; if (close(descriptor) != 0) file_ok = false; if (!file_ok) { (void)unlink(temporary); return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; } if (!trainlog_presentation_config_path(path, sizeof(path)) || rename(temporary, path) != 0) { (void)unlink(temporary); return TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; } /* CONTRACT: rename supplies atomic replacement; syncing its directory * makes the new directory entry durable before success is reported. */ /* INVARIANT: after rename the selected preference is committed and must * not be described or treated as rolled back, even if directory fsync * cannot prove crash durability. */ return sync_parent_directory(path) ? TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE : TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN; } void trainlog_presentation_resolve_save( TrainlogPresentationLanguage previous_language, TrainlogPresentationSaveResult result) { /* CONTRACT: only a pre-rename failure rolls runtime presentation back. * Both committed outcomes retain the selected language because the new * file is already visible after rename. */ if (result == TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED) (void)trainlog_presentation_set_language(previous_language); }