trainlog/tui/src/presentation.c
2026-09-16 07:46:01 +02:00

1233 lines
76 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file presentation.c
* @brief Locale-independent French/English TUI presentation catalog.
*/
#include "trainlog/presentation.h"
#include <errno.h>
#include <fcntl.h>
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
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);
}