diff --git a/tui/include/trainlog/presentation.h b/tui/include/trainlog/presentation.h new file mode 100644 index 0000000..9ed56e9 --- /dev/null +++ b/tui/include/trainlog/presentation.h @@ -0,0 +1,76 @@ +/* + * Trainlog local presentation preferences and translations. + */ +#ifndef TRAINLOG_PRESENTATION_H +#define TRAINLOG_PRESENTATION_H + +#include +#include +#include + +typedef enum TrainlogPresentationLanguage { + TRAINLOG_PRESENTATION_FRENCH = 0, + TRAINLOG_PRESENTATION_ENGLISH +} TrainlogPresentationLanguage; + +typedef enum TrainlogPresentationSaveResult { + TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED = 0, + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE, + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN +} TrainlogPresentationSaveResult; + +/* WHY: language is a local presentation choice, not workout data. + * CONTRACT: init always selects French. load accepts only the versioned v1 + * file and leaves French selected when it is absent or invalid. Neither + * function reads or mutates the canonical database. + * INVARIANT: the only runtime values are French and English. */ +void trainlog_presentation_init(void); +bool trainlog_presentation_load(void); +TrainlogPresentationSaveResult trainlog_presentation_save(void); +void trainlog_presentation_resolve_save( + TrainlogPresentationLanguage previous_language, + TrainlogPresentationSaveResult result); +TrainlogPresentationLanguage trainlog_presentation_language(void); +bool trainlog_presentation_set_language(TrainlogPresentationLanguage language); +const char *trainlog_presentation_language_code( + TrainlogPresentationLanguage language); +const char *trainlog_presentation_language_name( + TrainlogPresentationLanguage language); + +/* WHY: stable keys keep screen code independent of wording and language. + * CONTRACT: returned storage is static and must not be freed. Unknown/NULL + * keys return a visible deterministic fallback and never user/catalog data. + * INVARIANT: translations have no side effects. */ +const char *trainlog_presentation_text(const char *key); + +/* Transitional centralized lookup for presentation format strings. It exists + * so large legacy renderers can cross one translation boundary without + * language conditionals; new UI must use stable keys above. Unknown strings + * are returned byte-for-byte, preserving user and protocol literals. */ +const char *trainlog_presentation_source(const char *source); +const char *trainlog_presentation_month_label(const char *source, + char *output, size_t output_size); + +/* Format one translated presentation string into caller-owned storage. This + * is the serialization-safe adapter used by strict renderer APIs: format + * placeholders are identical in both catalog columns. */ +int trainlog_presentation_vformat(char *output, size_t capacity, + const char *source, va_list arguments); +int trainlog_presentation_format(char *output, size_t capacity, + const char *source, ...); +/* Locale-neutral formatting for editable numeric seed text. This is not + * presentation: both UI languages and every host locale receive ASCII '.'. */ +int trainlog_presentation_format_input(char *output, size_t capacity, + const char *format, ...) __attribute__((format(printf, 3, 4))); +int trainlog_presentation_format_key(char *output, size_t capacity, + const char *key, ...); + +/* Format YYYY-MM-DD as app-owned compact presentation text. Stored timestamp + * bytes are borrowed and never changed; French uses DD/MM, English MM/DD. */ +bool trainlog_presentation_compact_date(const char *iso_date, bool with_year, + char *output, size_t capacity); + +/* Exposed for focused persistence tests; normal callers use load/save. */ +bool trainlog_presentation_config_path(char *output, size_t capacity); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 67b5a5a..46c5733 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -141,6 +141,7 @@ trainlog_core_dep = declare_dependency( trainlog_tui_sources = files( 'src/main.c', 'src/app_shell.c', + 'src/presentation.c', 'src/chart.c', 'src/sync_screen_action.c', 'src/terminal.c', @@ -598,6 +599,7 @@ test_sync_screen_action = executable( 'test_sync_screen_action', 'tests/test_sync_screen_action.c', 'src/sync_screen_action.c', + 'src/presentation.c', dependencies: [trainlog_core_dep, notcurses_dep], c_args: strict_c_args, ) @@ -654,6 +656,7 @@ test_tui_workflows = executable( 'tests/test_tui_workflows.c', 'src/app_shell.c', 'src/chart.c', + 'src/presentation.c', 'src/theme.c', 'src/sync_screen_action.c', dependencies: trainlog_core_dep, @@ -666,6 +669,7 @@ test_chart = executable( 'test_chart', 'tests/test_chart.c', 'src/chart.c', + 'src/presentation.c', include_directories: trainlog_include, dependencies: m_dep, c_args: strict_c_args, @@ -736,8 +740,28 @@ test_app_shell = executable( 'test_app_shell', 'tests/test_app_shell.c', 'src/app_shell.c', + 'src/presentation.c', dependencies: trainlog_core_dep, c_args: strict_c_args, ) test('app_shell', test_app_shell) + +test_presentation = executable( + 'test_presentation', + 'tests/test_presentation.c', + 'src/presentation.c', + 'src/app_shell.c', + 'src/sync_screen_action.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('presentation_i18n', test_presentation) + +test( + 'presentation_catalog_completeness', + python3, + args: [meson.project_source_root() / 'tui/tests/test_presentation_catalog.py'], + workdir: meson.project_source_root(), +) diff --git a/tui/src/app_shell.c b/tui/src/app_shell.c index 14f41b6..7c19254 100644 --- a/tui/src/app_shell.c +++ b/tui/src/app_shell.c @@ -11,6 +11,7 @@ #include #include "trainlog/terminal.h" +#include "trainlog/presentation.h" static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY], const char *value) @@ -501,37 +502,24 @@ TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key) const char *trainlog_route_title(TrainlogAppRoute route) { - switch (route) { - case TRAINLOG_ROUTE_HOME: return "Accueil"; - case TRAINLOG_ROUTE_SESSIONS: return "Séances"; - case TRAINLOG_ROUTE_SESSION_CURRENT: return "Séances / Séance en cours"; - case TRAINLOG_ROUTE_SESSION_GENERATOR: return "Séances / Programmer"; - case TRAINLOG_ROUTE_SESSION_MANUAL: return "Séances / Nouvelle séance"; - case TRAINLOG_ROUTE_SESSIONS_COMPLETED: return "Séances / Effectuées"; - case TRAINLOG_ROUTE_SESSION_DETAIL: return "Séances / Détail"; - case TRAINLOG_ROUTE_EXERCISES: return "Exercices / Catalogue"; - case TRAINLOG_ROUTE_EXERCISE_DETAIL: return "Exercices / Fiche"; - case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: return "Exercices / Connaissances"; - case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: return "Statistiques / Performance"; - case TRAINLOG_ROUTE_EXERCISE_MAX: return "Statistiques / MAX mesuré"; - case TRAINLOG_ROUTE_EQUIPMENT: return "Équipements / Catalogue"; - case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: return "Équipements / Fiche"; - case TRAINLOG_ROUTE_STATS: return "Statistiques"; - case TRAINLOG_ROUTE_STATS_TRAINING: return "Statistiques / Entraînement"; - case TRAINLOG_ROUTE_STATS_EXERCISE: return "Statistiques / Par exercice"; - case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: return "Statistiques / Exercice"; - case TRAINLOG_ROUTE_STATS_ZONES: return "Statistiques / Zones"; - case TRAINLOG_ROUTE_BODY: return "Statistiques / Mensurations"; - case TRAINLOG_ROUTE_BODY_DETAIL: return "Mensurations / Relevé"; - case TRAINLOG_ROUTE_BODY_METRIC: return "Mensurations / Historique"; - case TRAINLOG_ROUTE_BODY_TRENDS: return "Mensurations / 12 mois"; - case TRAINLOG_ROUTE_BODY_GLOBAL: return "Mensurations / Vue globale"; - case TRAINLOG_ROUTE_BODY_ANALYTICS: return "Mensurations / Analyse"; - case TRAINLOG_ROUTE_MAX: return "Statistiques / Capacités MAX"; - case TRAINLOG_ROUTE_SYNC: return "Synchronisation"; - case TRAINLOG_ROUTE_SETTINGS: return "Paramètres"; - default: return "Trainlog"; - } + static const char *const keys[TRAINLOG_ROUTE_COUNT] = { + "nav.home", "nav.sessions", "route.session.current", + "route.session.generator", "route.session.manual", + "route.session.completed", "route.session.detail", + "route.exercise.catalog", "route.exercise.detail", + "route.exercise.knowledge", "route.exercise.performance", + "route.exercise.max", "route.equipment.catalog", + "route.equipment.detail", "nav.statistics", "route.stats.training", + "route.stats.exercise", "route.stats.exercise.detail", + "route.stats.zones", "route.body", "route.body.detail", + "route.body.metric", "route.body.trends", "route.body.global", + "route.body.analytics", "route.max", "nav.sync", "nav.settings" + }; + /* INVARIANT: enum order is the stable route identity; wording is resolved + * only at this presentation boundary. */ + if (route >= TRAINLOG_ROUTE_HOME && route < TRAINLOG_ROUTE_COUNT) + return trainlog_presentation_text(keys[route]); + return "Trainlog"; } TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route) diff --git a/tui/src/chart.c b/tui/src/chart.c index d7fafdf..2a8a4c9 100644 --- a/tui/src/chart.c +++ b/tui/src/chart.c @@ -9,10 +9,26 @@ #include #include #include +#include #include #include #include +#include "trainlog/presentation.h" + +static void chart_surface_printf(TrainlogSurface *surface, int row, int column, + const char *format, ...) +{ + va_list arguments; + char value[512]; + va_start(arguments, format); + (void)trainlog_presentation_vformat(value, sizeof(value), format, arguments); + va_end(arguments); + trainlog_surface_printf(surface, row, column, "%s", value); +} + +#define trainlog_surface_printf chart_surface_printf + #define CHART_LABEL_WIDTH 12 #define CHART_MIN_WIDTH 24 #define CHART_MIN_HEIGHT 6 @@ -160,21 +176,18 @@ static void short_date(const TrainlogChartPoint *point, char output[6]) { time_t seconds; struct tm value; + char iso_date[11]; if (point != NULL && point->timestamp_label != NULL && strlen(point->timestamp_label) >= 10U && point->timestamp_label[4] == '-' && point->timestamp_label[7] == '-') { - output[0] = point->timestamp_label[8]; - output[1] = point->timestamp_label[9]; - output[2] = '/'; - output[3] = point->timestamp_label[5]; - output[4] = point->timestamp_label[6]; - output[5] = '\0'; - return; + if (trainlog_presentation_compact_date(point->timestamp_label, false, + output, 6U)) return; } seconds = point != NULL ? (time_t)point->timestamp : (time_t)-1; if (point == NULL || (int64_t)seconds != point->timestamp || gmtime_r(&seconds, &value) == NULL || - strftime(output, 6U, "%d/%m", &value) != 5U) + strftime(iso_date, sizeof(iso_date), "%Y-%m-%d", &value) != 10U || + !trainlog_presentation_compact_date(iso_date, false, output, 6U)) (void)snprintf(output, 6U, "--/--"); } @@ -184,12 +197,13 @@ static void render_empty(TrainlogSurface *surface, TrainlogChartRect rect, trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); trainlog_surface_printf(surface, rect.top, rect.left, "%s", - title != NULL ? title : "ÉVOLUTION DANS LE TEMPS"); + title != NULL ? trainlog_presentation_source(title) + : trainlog_presentation_text("chart.timeline")); trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); if (rect.height > 2) trainlog_surface_printf(surface, rect.top + 2, rect.left, - "Aucune donnée disponible."); + "%s", trainlog_presentation_text("empty.no_data")); } void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect, @@ -229,7 +243,8 @@ void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); trainlog_surface_printf(surface, rect.top, rect.left, "%s", - title != NULL ? title : "ÉVOLUTION DANS LE TEMPS"); + title != NULL ? trainlog_presentation_source(title) + : trainlog_presentation_text("chart.timeline")); if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) { trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); @@ -359,10 +374,12 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); trainlog_surface_printf(surface, rect.top, rect.left, "%s", - title != NULL ? title : "TOTAUX PAR PÉRIODE"); + title != NULL ? trainlog_presentation_source(title) + : trainlog_presentation_text("chart.totals")); if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) { trainlog_surface_printf(surface, rect.top + 2, rect.left, - "%zu périodes · %.2f %s", count, points[count - 1U].value, safe_unit); + "%zu périodes · %.2f %s", count, + points[count - 1U].value, safe_unit); return; } plot_left = rect.left + CHART_LABEL_WIDTH; @@ -410,9 +427,13 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect if (column < plot_left) column = plot_left; if (column > plot_left + plot_width - 11) column = plot_left + plot_width - 11; - if (label != NULL) + if (label != NULL) { + char localized_label[32]; trainlog_surface_printf(surface, - plot_top + plot_height + 1, column, "%.11s", label); + plot_top + plot_height + 1, column, "%.11s", + trainlog_presentation_month_label(label, localized_label, + sizeof(localized_label))); + } } } else { short_date(&first_domain, first); short_date(&last_domain, last); diff --git a/tui/src/presentation.c b/tui/src/presentation.c new file mode 100644 index 0000000..78002d5 --- /dev/null +++ b/tui/src/presentation.c @@ -0,0 +1,1233 @@ +/** + * @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); +} diff --git a/tui/src/sync_screen_action.c b/tui/src/sync_screen_action.c index 4928a65..803c12a 100644 --- a/tui/src/sync_screen_action.c +++ b/tui/src/sync_screen_action.c @@ -6,6 +6,7 @@ #include "trainlog/sync_screen_action.h" #include "trainlog/terminal.h" +#include "trainlog/presentation.h" static TrainlogSyncScreenAction sync_screen_action( TrainlogSyncScreenEffect effect, @@ -143,14 +144,14 @@ const char *trainlog_sync_screen_confirmation( ) { if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) { - return "Importer Android vers le PC maintenant ?"; + return trainlog_presentation_text("sync.confirm.import"); } if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) { - return "Publier le catalogue du PC vers Android maintenant ?"; + return trainlog_presentation_text("sync.confirm.publish"); } - return "Synchroniser Android et PC dans les deux sens maintenant ?"; + return trainlog_presentation_text("sync.confirm.both"); } const char *trainlog_sync_screen_footer( @@ -158,8 +159,8 @@ const char *trainlog_sync_screen_footer( ) { if (terminal_columns >= 100) { - return "s Synchroniser maintenant (PC↔Android) r Actualiser appareil Échap retour"; + return trainlog_presentation_text("sync.footer.wide"); } - return "s Synchroniser PC↔Android r Actualiser Échap retour"; + return trainlog_presentation_text("sync.footer.compact"); } diff --git a/tui/src/tui.c b/tui/src/tui.c index 2e50b6c..a8d5241 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include "trainlog/terminal.h" #include "trainlog/app_shell.h" +#include "trainlog/presentation.h" #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" @@ -53,6 +55,45 @@ #include "timestamp.h" #include "database_internal.h" +static void presentation_surface_printf(TrainlogSurface *surface, int row, + int column, const char *format, ...) +{ + va_list arguments; + va_list copy; + char *value; + int count; + va_start(arguments, format); + va_copy(copy, arguments); + count = trainlog_presentation_vformat(NULL, 0U, format, copy); + va_end(copy); + if (count >= 0) { + value = malloc((size_t)count + 1U); + if (value != NULL) { + (void)trainlog_presentation_vformat(value, (size_t)count + 1U, + format, arguments); + trainlog_surface_printf(surface, row, column, "%s", value); + free(value); + } + } + va_end(arguments); +} + +static void presentation_terminal_printf(TrainlogTerminal *terminal, int row, + int column, const char *format, ...) +{ + va_list arguments; + char value[512]; + va_start(arguments, format); + (void)trainlog_presentation_vformat(value, sizeof(value), format, arguments); + va_end(arguments); + trainlog_terminal_printf(terminal, row, column, "%s", value); +} + +/* All legacy renderer format literals cross the centralized catalog. Unknown + * strings pass through, preserving persisted names and technical literals. */ +#define trainlog_surface_printf presentation_surface_printf +#define trainlog_terminal_printf presentation_terminal_printf + #define MAX_EXERCISES 128U #define MAX_SESSION_EXERCISES 32U #define MAX_SETS_PER_EXERCISE 64U @@ -162,9 +203,11 @@ static size_t equipment_collect(TrainlogDatabase *database, const char *query, static const char *equipment_origin_label(TrainlogEquipmentOrigin origin) { - if (origin == TRAINLOG_EQUIPMENT_SUPPLIED) return "fourni"; - if (origin == TRAINLOG_EQUIPMENT_CUSTOM) return "personnalisé"; - return "inconnu"; + if (origin == TRAINLOG_EQUIPMENT_SUPPLIED) + return trainlog_presentation_source("fourni"); + if (origin == TRAINLOG_EQUIPMENT_CUSTOM) + return trainlog_presentation_source("personnalisé"); + return trainlog_presentation_source("inconnu"); } static void generate_custom_equipment_uuid( @@ -177,12 +220,14 @@ static void generate_custom_equipment_uuid( static const char *session_type_label(TrainlogSessionType type) { - return type == TRAINLOG_SESSION_MAX_TEST ? "Test de max" : "Entraînement"; + return trainlog_presentation_source(type == TRAINLOG_SESSION_MAX_TEST + ? "Test de max" : "Entraînement"); } static const char *session_type_history_label(TrainlogSessionType type) { - return type == TRAINLOG_SESSION_MAX_TEST ? "[MAX]" : "[ENTRAINEMENT]"; + return type == TRAINLOG_SESSION_MAX_TEST ? "[MAX]" : + trainlog_presentation_text("label.training.upper"); } static bool parse_double_positive(const char *text, double *output) @@ -209,14 +254,14 @@ static const char *exercise_load_mode_label( { switch (mode) { case TRAINLOG_LOAD_EXTERNAL: - return "charge externe"; + return trainlog_presentation_source("charge externe"); case TRAINLOG_LOAD_ASSISTANCE: - return "assistance"; + return trainlog_presentation_source("assistance"); case TRAINLOG_LOAD_NONE: default: - return "sans charge"; + return trainlog_presentation_source("sans charge"); } } @@ -253,7 +298,7 @@ static void format_compact_max_weight( { size_t length; if (output == NULL || output_size == 0U) return; - (void)snprintf(output, output_size, "%.2f", value); + (void)trainlog_presentation_format(output, output_size, "%.2f", value); length = strlen(output); while (length > 0U && output[length - 1U] == '0') output[--length] = '\0'; @@ -274,12 +319,8 @@ static void exercise_format_performance( } if (point->has_performance == 0) { - (void)snprintf( - output, - output_size, - "%s", - "aucune série réussie" - ); + (void)trainlog_presentation_format_key(output, output_size, + "summary.performance.none"); return; } @@ -299,7 +340,7 @@ static void exercise_format_performance( duration, sizeof(duration) ) != TRAINLOG_STATUS_OK) { - (void)snprintf( + (void)trainlog_presentation_format( duration, sizeof(duration), "%d s", @@ -320,13 +361,8 @@ static void exercise_format_performance( point->load_mode == TRAINLOG_LOAD_ASSISTANCE ) { - (void)snprintf( - output, - output_size, - "%.1f kg aide × %s", - point->weight_kg, - duration - ); + (void)trainlog_presentation_format_key(output, output_size, + "summary.assisted.duration", point->weight_kg, duration); } else { (void)snprintf( output, @@ -341,7 +377,7 @@ static void exercise_format_performance( if (point->load_mode == TRAINLOG_LOAD_EXTERNAL) { - (void)snprintf( + (void)trainlog_presentation_format( output, output_size, "%.1f kg × %d reps", @@ -352,15 +388,10 @@ static void exercise_format_performance( point->load_mode == TRAINLOG_LOAD_ASSISTANCE ) { - (void)snprintf( - output, - output_size, - "%.1f kg aide × %d reps", - point->weight_kg, - point->metric_value - ); + (void)trainlog_presentation_format_key(output, output_size, + "summary.assisted.reps", point->weight_kg, point->metric_value); } else { - (void)snprintf( + (void)trainlog_presentation_format( output, output_size, "%d reps", @@ -610,6 +641,13 @@ static const TrainlogBodyMetricView BODY_METRICS[] = { {TRAINLOG_BODY_METRIC_RIGHT_CALF, "Mollet droit", "cm"} }; +static const char *body_metric_label(size_t index) +{ + return index < sizeof(BODY_METRICS) / sizeof(BODY_METRICS[0]) + ? trainlog_presentation_source(BODY_METRICS[index].label) + : trainlog_presentation_source("Mesure"); +} + /* TRAINLOG_GLOBAL_BODY_OVERLAY_HELPERS */ #define MAX_GLOBAL_BODY_DATES \ @@ -1003,23 +1041,24 @@ static void draft_set_summary( duration, sizeof(duration) ); - (void)snprintf(output, output_size, "Continu · %s", duration); + (void)trainlog_presentation_format_key(output, output_size, + "summary.continuous", duration); } else if (draft->input.set_count == 0U && draft->input.target_sets > 0 && draft->input.target_reps > 0) { if (draft->input.target_has_weight) { - (void)snprintf(output, output_size, - "Plan %d×%d · %.2f kg · repos %d s · 0 réalisée", + (void)trainlog_presentation_format_key(output, output_size, + "summary.plan.weight", draft->input.target_sets, draft->input.target_reps, draft->input.target_weight_kg, draft->input.rest_seconds); } else { - (void)snprintf(output, output_size, - "Plan %d×%d · charge absente · repos %d s · 0 réalisée", + (void)trainlog_presentation_format_key(output, output_size, + "summary.plan.no_load", draft->input.target_sets, draft->input.target_reps, draft->input.rest_seconds); } } else { - int written = snprintf(output, output_size, "%zu séries · ", - draft->input.set_count); + int written = trainlog_presentation_format_key(output, output_size, + "summary.sets.prefix", draft->input.set_count); if (written < 0) { output[0] = '\0'; return; @@ -1044,12 +1083,12 @@ static void draft_set_summary( draft->sets[index].has_weight ? "" : "—", draft->sets[index].has_weight ? "kg" : ""); if (draft->sets[index].has_weight) { - written = snprintf(fragment, sizeof(fragment), + written = trainlog_presentation_format(fragment, sizeof(fragment), "%s%s×%.2fkg%s", index > 0U ? " / " : "", metric, draft->sets[index].weight_kg, draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE - ? " aide" : ""); + ? trainlog_presentation_text("summary.assistance.suffix") : ""); } } if (written < 0) { @@ -1266,10 +1305,10 @@ typedef struct TrainlogGeneratorPreviewItem { static const char *generator_goal_label(const char *goal_id) { - if (strcmp(goal_id, "general") == 0) return "Général"; - if (strcmp(goal_id, "strength") == 0) return "Force"; - if (strcmp(goal_id, "hypertrophy") == 0) return "Hypertrophie"; - if (strcmp(goal_id, "endurance") == 0) return "Endurance"; + if (strcmp(goal_id, "general") == 0) return trainlog_presentation_text("goal.general"); + if (strcmp(goal_id, "strength") == 0) return trainlog_presentation_text("goal.strength"); + if (strcmp(goal_id, "hypertrophy") == 0) return trainlog_presentation_text("goal.hypertrophy"); + if (strcmp(goal_id, "endurance") == 0) return trainlog_presentation_text("goal.endurance"); return goal_id; } @@ -1383,17 +1422,8 @@ static void body_short_date( return; } - (void)snprintf( - output, - 9U, - "%c%c/%c%c/%c%c", - timestamp[8], - timestamp[9], - timestamp[5], - timestamp[6], - timestamp[2], - timestamp[3] - ); + if (!trainlog_presentation_compact_date(timestamp, true, output, 9U)) + (void)snprintf(output, 9U, "%s", "--/--/--"); } static size_t body_record_metric_count( @@ -1467,49 +1497,26 @@ static void body_summary_text( } if (record->has_waist) { - (void)snprintf( - waist, - sizeof(waist), - "taille %.1f", - record->waist_cm - ); + (void)trainlog_presentation_format_key(waist, sizeof(waist), + "summary.waist.value", record->waist_cm); } else { - (void)snprintf( - waist, - sizeof(waist), - "%s", - "taille —" - ); + (void)trainlog_presentation_format_key(waist, sizeof(waist), + "summary.waist.none"); } if (record->has_chest) { - (void)snprintf( - chest, - sizeof(chest), - "poitrine %.1f", - record->chest_cm - ); + (void)trainlog_presentation_format_key(chest, sizeof(chest), + "summary.chest.value", record->chest_cm); } else { - (void)snprintf( - chest, - sizeof(chest), - "%s", - "poitrine —" - ); + (void)trainlog_presentation_format_key(chest, sizeof(chest), + "summary.chest.none"); } metric_count = body_record_metric_count(record); - (void)snprintf( - output, - output_size, - "%-9s %-9s %-14s %2zu valeur(s)", - weight, - waist, - chest, - metric_count - ); + (void)trainlog_presentation_format_key(output, output_size, + "summary.body", weight, waist, chest, metric_count); } static void body_input_from_record( @@ -2061,23 +2068,16 @@ static void session_history_datetime( return; } - (void)snprintf( - output, - 17U, - "%c%c/%c%c/%c%c%c%c %c%c:%c%c", - timestamp[8], - timestamp[9], - timestamp[5], - timestamp[6], - timestamp[0], - timestamp[1], - timestamp[2], - timestamp[3], - timestamp[11], - timestamp[12], - timestamp[14], - timestamp[15] - ); + { + char date[9]; + if (!trainlog_presentation_compact_date(timestamp, true, date, + sizeof(date))) { + (void)snprintf(output, 17U, "%s", "--/--/---- --:--"); + return; + } + (void)snprintf(output, 17U, "%s %c%c:%c%c", date, + timestamp[11], timestamp[12], timestamp[14], timestamp[15]); + } } static bool sync_history_path( @@ -2136,67 +2136,6 @@ static bool sync_history_path( output_size; } -static bool sync_runs_path( - const char *sync_id, - char *output, - size_t output_size -) -{ - const char *data_home = - getenv( - "XDG_DATA_HOME" - ); - - const char *home = - getenv( - "HOME" - ); - - int written; - - if ( - sync_id == NULL || - sync_id[0] == '\0' || - output == NULL || - output_size == 0U - ) { - return false; - } - - if ( - data_home != NULL && - data_home[0] != '\0' - ) { - written = - snprintf( - output, - output_size, - "%s/trainlog/sync_runs/%s.txt", - data_home, - sync_id - ); - } else if ( - home != NULL && - home[0] != '\0' - ) { - written = - snprintf( - output, - output_size, - "%s/.local/share/trainlog/sync_runs/%s.txt", - home, - sync_id - ); - } else { - return false; - } - - return - written >= 0 && - (size_t)written < - output_size; -} - static void sync_history_load( TrainlogSyncHistoryEntry *output, size_t capacity, @@ -2570,6 +2509,12 @@ typedef struct TrainlogSyncController { TrainlogStatus probe_status; bool probe_known; TrainlogSyncReport report; + /* WHY: report summary/error are durable diagnostic payloads, not UI copy. + * CONTRACT: the latest engine status is retained separately so the + * presenter can classify failures without parsing or rewriting payloads. + * INVARIANT: current-result rendering depends only on this status and the + * report's typed fields/counters. */ + TrainlogStatus latest_status; bool has_report; bool running; TrainlogSyncHistoryEntry history[SYNC_HISTORY_CAPACITY]; @@ -2706,6 +2651,8 @@ typedef struct TrainlogAppContext { TrainlogFocusTarget navigation_restore_focus; size_t content_selected; size_t content_scroll; + bool settings_language_save_failed; + bool settings_language_durability_uncertain; TrainlogListState list; TrainlogSearchState search; TrainlogListState saved_lists[TRAINLOG_ROUTE_COUNT]; @@ -2834,7 +2781,7 @@ static void body_controller_open_field(TrainlogBodyController *controller) char text[64] = ""; body_metric_slot(&controller->pending, controller->field, &present, &value); if (controller->editing && present != NULL && *present) - (void)snprintf(text, sizeof(text), "%.10g", *value); + (void)trainlog_presentation_format_input(text, sizeof(text), "%.10g", *value); trainlog_form_init(&controller->form, text); controller->phase = TRAINLOG_BODY_FORM; } @@ -2848,8 +2795,8 @@ static void body_controller_start_add(TrainlogAppContext *app) trainlog_time_now_rfc3339(controller->pending.observed_at, sizeof(controller->pending.observed_at)) != TRAINLOG_STATUS_OK) { controller->phase = TRAINLOG_BODY_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Impossible de préparer un identifiant ou une date de relevé."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.body.prepare"); return; } body_controller_open_field(controller); @@ -2888,16 +2835,16 @@ static bool body_controller_persist(TrainlogAppContext *app) : trainlog_database_insert_body_observation(app->database, &controller->pending); if (status != TRAINLOG_STATUS_OK) { - (void)snprintf(controller->message, sizeof(controller->message), - "Enregistrement impossible : au moins une mesure positive est requise."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.body.positive"); return false; } controller->dirty = false; controller->form.active = false; controller->phase = TRAINLOG_BODY_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), "%s", - controller->editing ? "Relevé corrigé; identité, date et séance conservées." - : "Relevé corporel enregistré."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), controller->editing + ? "msg.body.corrected" : "msg.body.saved"); if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) { app_shell_refresh_list(app); /* INVARIANT: a successful edit refreshes both halves of the body @@ -2955,8 +2902,8 @@ static bool app_shell_dispatch_body_controller(TrainlogAppContext *app, int key) strcmp(controller->form.text, "-") == 0) { *present = false; *value = 0.0; } else if (!parse_double_positive(controller->form.text, &parsed)) { - (void)snprintf(controller->message, sizeof(controller->message), - "Nombre positif invalide; le texte saisi est conservé."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.positive.invalid"); return true; } else { *present = true; *value = parsed; } controller->dirty = true; @@ -3033,8 +2980,8 @@ static bool app_shell_dispatch_profile_controller(TrainlogAppContext *app, controller->phase == TRAINLOG_PROFILE_FORMULA) { if (strcmp(controller->form.text, "1") != 0 && strcmp(controller->form.text, "2") != 0) { - (void)snprintf(controller->message, sizeof(controller->message), - "Formule attendue : 1 homme ou 2 femme."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.profile.formula"); return true; } controller->pending.formula = strcmp(controller->form.text, "2") == 0 @@ -3042,26 +2989,26 @@ static bool app_shell_dispatch_profile_controller(TrainlogAppContext *app, : TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; controller->phase = TRAINLOG_PROFILE_HEIGHT; controller->return_phase = TRAINLOG_PROFILE_HEIGHT; - { char text[64]; (void)snprintf(text, sizeof(text), "%.1f", + { char text[64]; (void)trainlog_presentation_format_input(text, sizeof(text), "%.1f", controller->pending.height_cm); trainlog_form_init(&controller->form, text); } } else if (result == TRAINLOG_FORM_SUBMIT) { double height; if (!parse_double_positive(controller->form.text, &height) || height < 100.0 || height > 250.0) { - (void)snprintf(controller->message, sizeof(controller->message), - "Taille attendue entre 100 et 250 cm; texte conservé."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.profile.height"); return true; } controller->pending.height_cm = height; if (!body_analytics_profile_save(&controller->pending)) { - (void)snprintf(controller->message, sizeof(controller->message), - "Impossible d’enregistrer le profil local; champs conservés."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.profile.save.fail"); return true; } controller->dirty = false; controller->phase = TRAINLOG_PROFILE_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Profil d’estimation corporelle enregistré localement."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.profile.saved"); } else if (result == TRAINLOG_FORM_CANCEL) { if (controller->dirty) { controller->return_phase = controller->phase; @@ -3130,14 +3077,14 @@ static bool session_controller_load_picker(TrainlogAppContext *app) session->picker_selected = 0U; if (trainlog_database_list_exercises(app->database, session->picker, MAX_EXERCISES, &session->picker_count) != TRAINLOG_STATUS_OK) { - (void)snprintf(session->message, sizeof(session->message), - "Impossible de lire le catalogue d’exercices."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.catalog.read"); session->phase = TRAINLOG_SESSION_MESSAGE; return false; } if (session->picker_count == 0U) { - (void)snprintf(session->message, sizeof(session->message), - "Ajoutez d’abord au moins un exercice au catalogue."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.catalog.empty"); session->phase = TRAINLOG_SESSION_MESSAGE; return false; } @@ -3265,8 +3212,8 @@ static bool session_controller_actuals_valid(TrainlogSessionController *session) { size_t index; if (session->draft_count == 0U) { - (void)snprintf(session->message, sizeof(session->message), - "Ajoutez au moins un exercice avant d’enregistrer."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.session.empty"); return false; } for (index = 0U; index < session->draft_count; ++index) { @@ -3276,8 +3223,9 @@ static bool session_controller_actuals_valid(TrainlogSessionController *session) ? input->continuous_duration_seconds > 0 : input->set_count > 0U); if (!valid) { session->selected = index; - (void)snprintf(session->message, sizeof(session->message), - "Saisissez un résultat réel pour %.120s.", session->drafts[index].name); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.actual.required", + session->drafts[index].name); return false; } } @@ -3319,15 +3267,15 @@ static bool session_controller_generate(TrainlogAppContext *app) status = trainlog_session_generate_from_database(app->database, &request, &session->generated); if (status != TRAINLOG_STATUS_OK) { - (void)snprintf(session->message, sizeof(session->message), - "Génération refusée (%d).", (int)status); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.generation.rejected", (int)status); session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG; session->phase = TRAINLOG_SESSION_MESSAGE; return false; } if (session->generated.exercise_count == 0U) { - (void)snprintf(session->message, sizeof(session->message), - "Pas assez d’exercices résolus et compatibles; rien n’a été inventé."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.generation.empty"); session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG; session->phase = TRAINLOG_SESSION_MESSAGE; return false; @@ -3413,8 +3361,8 @@ static bool session_controller_accept_generator(TrainlogAppContext *app) { TrainlogSessionController *session = &app->session; if (session->has_draft) { - (void)snprintf(session->message, sizeof(session->message), - "Une séance est déjà en cours. Reprenez-la ou gardez cet aperçu."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.session.exists.preview"); session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; session->phase = TRAINLOG_SESSION_MESSAGE; return false; @@ -3442,8 +3390,8 @@ static const TrainlogAppRoute shell_sections[] = { TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, TRAINLOG_ROUTE_SETTINGS }; static const char *const shell_section_labels[] = { - "Accueil", "Séances", "Exercices", "Statistiques", "Synchronisation", - "Paramètres" + "nav.home", "nav.sessions", "nav.exercises", "nav.statistics", "nav.sync", + "nav.settings" }; static void app_shell_destroy_surfaces(TrainlogAppContext *app) @@ -3502,7 +3450,8 @@ static void app_shell_add_action(TrainlogAppContext *app, const char *identifier int key, const char *label, unsigned priority, TrainlogIntent intent, TrainlogAppRoute route) { - TrainlogAction action = {identifier, key, label, true, priority, intent, route}; + TrainlogAction action = {identifier, key, + trainlog_presentation_source(label), true, priority, intent, route}; (void)trainlog_actions_add(&app->actions, &action); } @@ -4408,9 +4357,6 @@ static void sync_controller_load_history(TrainlogSyncController *sync) static void sync_controller_open_detail(TrainlogSyncController *sync) { const TrainlogSyncHistoryEntry *entry; - char path[PATH_MAX + 1U]; - FILE *file; - size_t used; char *cursor; sync->detail_line_count = 0U; sync->detail_scroll = 0U; @@ -4419,41 +4365,19 @@ static void sync_controller_open_detail(TrainlogSyncController *sync) return; entry = &sync->history[sync->selected]; if (entry->sync_id[0] == '\0') return; - /* CONTRACT: known legacy one-way records remain readable internal history, - * but their stored diagnostic prose predates the unified PC↔Android TUI and - * may expose prohibited directional modes. Present only parsed neutral - * metadata; never rewrite or reinterpret the retained artifact. */ - if (entry->direction_known && - entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL) { - (void)snprintf(sync->detail_text, sizeof(sync->detail_text), - "Synchronisation historique PC↔Android\n" - "Horodatage : %s\n" - "État : %s\n" - "Détail hérité masqué (entrée historique)", - entry->timestamp, entry->success ? "réussie" : "échouée"); - sync->detail_lines[sync->detail_line_count++] = sync->detail_text; - cursor = sync->detail_text; - while (*cursor != '\0' && - sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) { - if (*cursor == '\n') { - *cursor = '\0'; - if (cursor[1] != '\0') - sync->detail_lines[sync->detail_line_count++] = cursor + 1; - } - ++cursor; - } - sync->showing_detail = true; - return; - } - if (!sync_runs_path(entry->sync_id, - path, sizeof(path))) return; - file = fopen(path, "rb"); - if (file == NULL) return; - used = fread(sync->detail_text, 1U, - sizeof(sync->detail_text) - 1U, file); - sync->detail_text[used] = '\0'; - (void)fclose(file); - if (used == 0U) return; + /* WHY: history artifacts intentionally retain byte-exact diagnostics for + * logs and support, but arbitrary stored prose is not localized UI copy. + * CONTRACT: normal history presents only parsed metadata and labels old + * directional records neutrally; the files themselves remain untouched. + * INVARIANT: no history summary or run-detail byte reaches this surface. */ + (void)trainlog_presentation_format_key(sync->detail_text, + sizeof(sync->detail_text), + entry->direction_known && + entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL + ? "sync.history.legacy" : "sync.history.entry", + entry->timestamp, + trainlog_presentation_text(entry->success + ? "sync.state.success" : "sync.state.failure")); cursor = sync->detail_text; sync->detail_lines[sync->detail_line_count++] = cursor; while (*cursor != '\0' && @@ -4468,27 +4392,51 @@ static void sync_controller_open_detail(TrainlogSyncController *sync) sync->showing_detail = true; } -static const char *sync_controller_status_error(TrainlogStatus status) +static const char *sync_controller_status_key(TrainlogStatus status) { switch (status) { case TRAINLOG_STATUS_CONFLICT: - return "Synchronisation refusée : une autre exécution est active."; + return "sync.result.failure.conflict"; case TRAINLOG_STATUS_NOT_FOUND: - return "Synchronisation impossible : aucun appareil MTP Trainlog détecté."; + return "sync.result.failure.not_found"; case TRAINLOG_STATUS_INVALID_ARGUMENT: - return "Synchronisation refusée : données ou demande invalides."; + return "sync.result.failure.invalid"; case TRAINLOG_STATUS_DATABASE_ERROR: - return "Synchronisation interrompue : erreur de base de données locale."; + return "sync.result.failure.database"; case TRAINLOG_STATUS_SCHEMA_UNSUPPORTED: - return "Synchronisation refusée : version de données non prise en charge."; + return "sync.result.failure.version"; case TRAINLOG_STATUS_SYSTEM_ERROR: - return "Synchronisation interrompue : erreur d’accès appareil ou fichier."; + return "sync.result.failure.system"; case TRAINLOG_STATUS_OK: default: - return "Synchronisation interrompue sans rapport de réussite."; + return "sync.result.failure.no_report"; } } +static void sync_controller_present_result(const TrainlogSyncController *sync, + char *output, size_t capacity) +{ + const TrainlogSyncReport *report; + if (sync == NULL || output == NULL || capacity == 0U) return; + report = &sync->report; + /* CONTRACT: success requires agreement between the typed engine status and + * report flag. No diagnostic string is inspected, translated, or copied. */ + if (sync->latest_status != TRAINLOG_STATUS_OK || !report->success) { + (void)snprintf(output, capacity, "%s", + trainlog_presentation_text( + sync_controller_status_key(sync->latest_status))); + return; + } + (void)trainlog_presentation_format_key(output, capacity, + "sync.result.success.counts", + trainlog_sync_screen_direction_label(report->direction), + report->exercises_imported, report->exercises_reconciled, + report->exercises_skipped, report->sessions_imported, + report->sessions_skipped, report->body_imported, + report->body_skipped, report->equipment_definitions_imported, + report->equipment_definitions_skipped, report->catalog_published); +} + static void app_shell_open_exercise_analytics(TrainlogAppContext *app, TrainlogAppRoute route) { @@ -4541,18 +4489,18 @@ static bool equipment_controller_save(TrainlogAppContext *app) status = trainlog_database_create_custom_equipment(app->database, &controller->pending); if (status != TRAINLOG_STATUS_OK) { - (void)snprintf(controller->message, sizeof(controller->message), "%s", - status == TRAINLOG_STATUS_CONFLICT - ? "Conflit de définition; les champs sont conservés." - : "Équipement non créé : définition invalide ou conflit."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT + ? "msg.equipment.conflict" : "msg.equipment.invalid"); controller->phase = TRAINLOG_EQUIPMENT_LOAD; return false; } controller->dirty = false; controller->form.active = false; controller->phase = TRAINLOG_EQUIPMENT_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Équipement personnel créé : %.120s", controller->pending.display_name); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.equipment.created", + controller->pending.display_name); app_shell_refresh_list(app); if (!copy_ui_stable_id(app->list.selected_id, sizeof(app->list.selected_id), controller->pending.equipment_id)) return false; @@ -4641,8 +4589,8 @@ static bool app_shell_dispatch_equipment_controller(TrainlogAppContext *app, if ((controller->phase == TRAINLOG_EQUIPMENT_NAME || controller->phase == TRAINLOG_EQUIPMENT_TYPE) && controller->form.text[0] == '\0') { - (void)snprintf(controller->message, sizeof(controller->message), - "Ce champ est obligatoire; la saisie est conservée."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.field.required"); return true; } if (controller->phase == TRAINLOG_EQUIPMENT_NAME) { @@ -4691,8 +4639,8 @@ static void exercise_controller_start_edit(TrainlogAppContext *app) controller->pending.exercise_id, controller->primary_zone, controller->secondary_zones, &controller->secondary_count)) { controller->phase = TRAINLOG_EXERCISE_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Impossible de lire les zones actuelles."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.zones.read"); } else trainlog_form_init(&controller->form, controller->pending.name); app->focus = TRAINLOG_FOCUS_EDITOR; } @@ -4772,8 +4720,8 @@ static void exercise_controller_start_merge(TrainlogAppContext *app) controller->merge_source = app->exercise_detail; if (status != TRAINLOG_STATUS_OK) { controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; - (void)snprintf(controller->message, sizeof(controller->message), - "Impossible de charger les cibles de fusion."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.merge.targets"); } else { for (index = 0U; index < count; ++index) if (strcmp(all[index].exercise_id, @@ -4807,8 +4755,8 @@ static bool exercise_controller_save(TrainlogAppContext *app) if (!controller->editing && controller->pending.recording_mode == TRAINLOG_RECORDING_SETS && controller->primary_zone[0] == '\0') { - (void)snprintf(controller->message, sizeof(controller->message), - "Une zone principale est requise pour un exercice par séries."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.zone.required"); return false; } for (index = 0U; index < controller->secondary_count; ++index) @@ -4834,10 +4782,9 @@ static bool exercise_controller_save(TrainlogAppContext *app) controller->primary_zone[0] != '\0' ? controller->primary_zone : NULL, secondary_ids, controller->secondary_count, &created); if (status != TRAINLOG_STATUS_OK) { - (void)snprintf(controller->message, sizeof(controller->message), "%s", - status == TRAINLOG_STATUS_CONFLICT - ? "Conflit de nom ou de profil; tous les champs sont conservés." - : "Exercice non enregistré; tous les champs sont conservés."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT + ? "msg.exercise.conflict" : "msg.exercise.save.fail"); return false; } controller->dirty = false; @@ -4847,8 +4794,8 @@ static bool exercise_controller_save(TrainlogAppContext *app) TrainlogSessionController *session = &app->session; if (!session_controller_load_picker(app)) { controller->phase = TRAINLOG_EXERCISE_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Exercice créé, mais le sélecteur n’a pas pu être rechargé."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.exercise.reload"); return true; } for (index = 0U; index < session->picker_count; ++index) @@ -4874,9 +4821,10 @@ static bool exercise_controller_save(TrainlogAppContext *app) app->list.more_available); } controller->phase = TRAINLOG_EXERCISE_MESSAGE; - (void)snprintf(controller->message, sizeof(controller->message), - "Exercice %s : %.120s", controller->editing ? "modifié" : "créé", - created.name); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.exercise.result", + trainlog_presentation_text(controller->editing + ? "msg.exercise.edited" : "msg.exercise.created"), created.name); return true; } @@ -5061,8 +5009,8 @@ static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app, controller->message[0] = '\0'; } else if (result == TRAINLOG_FORM_SUBMIT || result == TRAINLOG_FORM_NEXT) { if (controller->form.text[0] == '\0') - (void)snprintf(controller->message, sizeof(controller->message), - "Le nom est obligatoire; la saisie est conservée."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.name.required"); else { (void)snprintf(controller->pending.name, sizeof(controller->pending.name), "%s", controller->form.text); @@ -5489,7 +5437,9 @@ static void app_shell_render_footer(TrainlogAppContext *app) trainlog_surface_printf(app->footer, 0, 24, "%s", contextual[1]->label); trainlog_surface_printf(app->footer, 1, 2, "F6 Navigation F7 Actions ? Aide %s", - app->navigation.current.route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour"); + trainlog_presentation_source( + app->navigation.current.route == TRAINLOG_ROUTE_HOME + ? "q Quitter" : "Échap Retour")); } static void app_shell_render_sidebar(TrainlogAppContext *app) @@ -5509,7 +5459,7 @@ static void app_shell_render_sidebar(TrainlogAppContext *app) focused ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); trainlog_surface_printf(app->sidebar, 1 + (int)index * 2, 1, "%c%c %-17s", focused ? '>' : ' ', active ? '[' : ' ', - shell_section_labels[index]); + trainlog_presentation_text(shell_section_labels[index])); if (active && app->layout.sidebar_expanded) { const char *subroute = NULL; switch (app->navigation.current.route) { @@ -5538,7 +5488,8 @@ static void app_shell_render_sidebar(TrainlogAppContext *app) } if (subroute != NULL) trainlog_surface_printf(app->sidebar, - 2 + (int)index * 2, 3, "%s", subroute); + 2 + (int)index * 2, 3, "%s", + trainlog_presentation_source(subroute)); } } } @@ -5584,10 +5535,12 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) ? "Type" : "Charge : 1 aucune · 2 externe · 3 assistance"; trainlog_surface_printf(app->content, row++, 2, "Créer un équipement personnel — retour à l’occurrence"); - trainlog_surface_printf(app->content, row++, 4, "%s", label); + trainlog_surface_printf(app->content, row++, 4, "%s", + trainlog_presentation_source(label)); trainlog_surface_printf(app->content, row++, 4, "> %s", session->form.text); if (session->message[0] != '\0') - trainlog_surface_printf(app->content, row + 1, 4, "%s", session->message); + trainlog_surface_printf(app->content, row + 1, 4, "%s", + trainlog_presentation_source(session->message)); } else if (session->phase == TRAINLOG_SESSION_PLANNING && session->selected < session->draft_count) { TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; @@ -5600,24 +5553,30 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) draft->input.target_duration_seconds); (void)snprintf(values[3], sizeof(values[3]), "%d", draft->input.rest_seconds); if (draft->input.target_has_weight) - (void)snprintf(values[4], sizeof(values[4]), "%.2f", + (void)trainlog_presentation_format(values[4], sizeof(values[4]), "%.2f", draft->input.target_weight_kg); else (void)snprintf(values[4], sizeof(values[4]), "—"); if (draft->input.recording_mode != TRAINLOG_RECORDING_SETS) { - (void)snprintf(values[0], sizeof(values[0]), "non applicable"); - (void)snprintf(values[3], sizeof(values[3]), "non applicable"); + (void)snprintf(values[0], sizeof(values[0]), "%s", + trainlog_presentation_text("value.not_applicable")); + (void)snprintf(values[3], sizeof(values[3]), "%s", + trainlog_presentation_text("value.not_applicable")); } if (draft->tracking_mode != TRAINLOG_TRACKING_REPS) - (void)snprintf(values[1], sizeof(values[1]), "non applicable"); + (void)snprintf(values[1], sizeof(values[1]), "%s", + trainlog_presentation_text("value.not_applicable")); if (draft->tracking_mode != TRAINLOG_TRACKING_DURATION) - (void)snprintf(values[2], sizeof(values[2]), "non applicable"); + (void)snprintf(values[2], sizeof(values[2]), "%s", + trainlog_presentation_text("value.not_applicable")); if (draft->input.load_mode == TRAINLOG_LOAD_NONE) - (void)snprintf(values[4], sizeof(values[4]), "non applicable"); + (void)snprintf(values[4], sizeof(values[4]), "%s", + trainlog_presentation_text("value.not_applicable")); trainlog_surface_printf(app->content, row++, 2, "Objectif prévu — séparé des valeurs réalisées"); for (index = 0U; index < 5U; ++index) trainlog_surface_printf(app->content, row++, 4, "%c %-32s %s", - index == session->planning_field ? '>' : ' ', labels[index], values[index]); + index == session->planning_field ? '>' : ' ', + trainlog_presentation_source(labels[index]), values[index]); if (row < app->layout.content.height - 2) trainlog_surface_printf(app->content, row++, 4, "%% sur la charge : calcul utilisateur 1..100%% du dernier MAX compatible"); @@ -5732,7 +5691,7 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) else trainlog_surface_printf(app->content, row++, 7, item->rationale_count > 0U && strcmp(item->rationale_codes[0], "compatible_max_unavailable") == 0 - ? "MAX compatible indisponible" + ? trainlog_presentation_text("ui.max.compatible.none") : "Aucune charge numérique qualifiée"); } } @@ -5759,7 +5718,8 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) trainlog_surface_printf(app->content, row, 4, "Entrée conserver et revenir · d Abandonner la proposition"); } else if (session->phase == TRAINLOG_SESSION_MESSAGE) { - trainlog_surface_printf(app->content, row++, 2, "%s", session->message); + trainlog_surface_printf(app->content, row++, 2, "%s", + trainlog_presentation_source(session->message)); trainlog_surface_printf(app->content, row, 2, "Entrée pour continuer"); } else { trainlog_surface_printf(app->content, row++, 2, "Type : %s", @@ -5777,7 +5737,7 @@ static void app_shell_render_session_controller(TrainlogAppContext *app) } if (session->message[0] != '\0') trainlog_surface_printf(app->content, app->layout.content.height - 2, - 2, "%s", session->message); + 2, "%s", trainlog_presentation_source(session->message)); } } @@ -5796,7 +5756,8 @@ static void app_shell_render_equipment_controller(TrainlogAppContext *app) return; } if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) { - trainlog_surface_printf(app->content, row++, 2, "%s", controller->message); + trainlog_surface_printf(app->content, row++, 2, "%s", + trainlog_presentation_source(controller->message)); trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir au catalogue"); return; } @@ -5810,8 +5771,9 @@ static void app_shell_render_equipment_controller(TrainlogAppContext *app) controller->pending.equipment_type[0] != '\0' ? controller->pending.equipment_type : "—"); trainlog_surface_printf(app->content, row++, 2, - "Charge : %s", controller->load_choice == 1 ? "aucune" : - controller->load_choice == 2 ? "externe" : "assistance"); + "Charge : %s", trainlog_presentation_source( + controller->load_choice == 1 ? "aucune" : + controller->load_choice == 2 ? "externe" : "assistance")); row += 1; if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) { trainlog_surface_printf(app->content, row++, 2, @@ -5820,14 +5782,15 @@ static void app_shell_render_equipment_controller(TrainlogAppContext *app) label = controller->phase == TRAINLOG_EQUIPMENT_NAME ? "Nom convivial" : controller->phase == TRAINLOG_EQUIPMENT_LABEL ? "Nom d’étiquette (optionnel)" : "Type"; - trainlog_surface_printf(app->content, row++, 2, "%s", label); + trainlog_surface_printf(app->content, row++, 2, "%s", + trainlog_presentation_source(label)); trainlog_surface_printf(app->content, row++, 4, "> %s%s", controller->form.text, controller->form.capacity_error ? " [200 octets maximum]" : ""); } if (controller->message[0] != '\0') trainlog_surface_printf(app->content, row + 1, 2, "%s", - controller->message); + trainlog_presentation_source(controller->message)); } static void app_shell_render_exercise_controller(TrainlogAppContext *app) @@ -5855,7 +5818,8 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app) return; } if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) { - trainlog_surface_printf(app->content, row++, 2, "%s", controller->message); + trainlog_surface_printf(app->content, row++, 2, "%s", + trainlog_presentation_source(controller->message)); trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir"); return; } @@ -5866,7 +5830,8 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app) ? "répétitions" : "durée"); trainlog_surface_printf(app->content, row++, 2, "Organisation : %s", controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS - ? "continue" : "séries"); + ? trainlog_presentation_source("continue") + : trainlog_presentation_source("séries")); if (controller->editing) trainlog_surface_printf(app->content, row++, 2, "Identifiant stable conservé : %s", @@ -5916,7 +5881,8 @@ static void app_shell_render_exercise_controller(TrainlogAppContext *app) } if (controller->message[0] != '\0') trainlog_surface_printf(app->content, - app->layout.content.height - 3, 2, "%s", controller->message); + app->layout.content.height - 3, 2, "%s", + trainlog_presentation_source(controller->message)); } static int app_shell_graph_height(const TrainlogAppContext *app, int top) @@ -6165,7 +6131,7 @@ static void app_shell_render_body_profile(TrainlogAppContext *app) if (length < 1) length = 1; if (length > bar_capacity) length = bar_capacity; trainlog_surface_printf(app->content, profile_row, 2, "%-19s", - BODY_METRICS[index + 1U].label); + body_metric_label(index + 1U)); trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); for (column = 0; column < length; ++column) @@ -6194,9 +6160,9 @@ static void app_shell_render_body_profile(TrainlogAppContext *app) } { char title[128]; - (void)snprintf(title, sizeof(title), - "ÉVOLUTION DANS LE TEMPS · ←/→ %s", - BODY_METRICS[app->body_metric_selected].label); + (void)trainlog_presentation_format_key(title, sizeof(title), + "chart.body.metric", trainlog_presentation_source( + body_metric_label(app->body_metric_selected))); app_shell_draw_body_points(app, series->points, series->count, evolution_top, app->layout.content.height - evolution_top - 1, BODY_METRICS[app->body_metric_selected].unit, title); @@ -6207,7 +6173,8 @@ static void app_shell_render_body_controller(TrainlogAppContext *app) { TrainlogBodyController *controller = &app->body_controller; const char *label = controller->field < 14U - ? BODY_METRICS[controller->field].label : "Mesure"; + ? body_metric_label(controller->field) + : trainlog_presentation_source("Mesure"); trainlog_surface_printf(app->content, 4, 2, "%s — champ %zu/14", controller->editing ? "Modifier le relevé" : "Nouveau relevé", controller->field + 1U); @@ -6217,7 +6184,8 @@ static void app_shell_render_body_controller(TrainlogAppContext *app) trainlog_surface_printf(app->content, 9, 2, "1 abandonner · 0/Échap reprendre"); } else if (controller->phase == TRAINLOG_BODY_MESSAGE) { - trainlog_surface_printf(app->content, 7, 2, "%s", controller->message); + trainlog_surface_printf(app->content, 7, 2, "%s", + trainlog_presentation_source(controller->message)); trainlog_surface_printf(app->content, 9, 2, "Entrée pour revenir"); } else { trainlog_surface_printf(app->content, 7, 2, "%s (%s)", label, @@ -6230,7 +6198,7 @@ static void app_shell_render_body_controller(TrainlogAppContext *app) : "vide signifie mesure non faite · valeur positive ajoute"); if (controller->message[0] != '\0') trainlog_surface_printf(app->content, 13, 2, "%s", - controller->message); + trainlog_presentation_source(controller->message)); } } @@ -6245,7 +6213,8 @@ static void app_shell_render_profile_controller(TrainlogAppContext *app) trainlog_surface_printf(app->content, 9, 2, "1 abandonner · 0/Échap reprendre"); } else if (controller->phase == TRAINLOG_PROFILE_MESSAGE) { - trainlog_surface_printf(app->content, 7, 2, "%s", controller->message); + trainlog_surface_printf(app->content, 7, 2, "%s", + trainlog_presentation_source(controller->message)); } else { trainlog_surface_printf(app->content, 7, 2, "%s", controller->phase == TRAINLOG_PROFILE_FORMULA @@ -6255,7 +6224,7 @@ static void app_shell_render_profile_controller(TrainlogAppContext *app) controller->form.text); if (controller->message[0] != '\0') trainlog_surface_printf(app->content, 11, 2, "%s", - controller->message); + trainlog_presentation_source(controller->message)); } } @@ -6279,14 +6248,15 @@ static void app_shell_render_body_detail(TrainlogAppContext *app) trainlog_surface_printf(app->content, row++, 2, "Date : %s", record->observed_at); trainlog_surface_printf(app->content, row++, 2, "Séance liée : %s", - record->session_id[0] != '\0' ? record->session_id : "aucune"); + record->session_id[0] != '\0' ? record->session_id + : trainlog_presentation_source("aucune")); for (index = app->content_scroll; index < 14U && row < app->layout.content.height - 1; ++index) { if (present[index]) trainlog_surface_printf(app->content, row++, 2, "%-22s %.2f %s", - BODY_METRICS[index].label, values[index], BODY_METRICS[index].unit); + body_metric_label(index), values[index], BODY_METRICS[index].unit); else trainlog_surface_printf(app->content, row++, 2, "%-22s —", - BODY_METRICS[index].label); + body_metric_label(index)); } } @@ -6407,11 +6377,8 @@ static void app_shell_dashboard_wide_performance_events(TrainlogAppContext *app, static void app_shell_dashboard_decimal_comma(double value, char *output, size_t output_size) { - char *separator; if (output == NULL || output_size == 0U) return; - (void)snprintf(output, output_size, "%.2f", value); - separator = strchr(output, '.'); - if (separator != NULL) *separator = ','; + (void)trainlog_presentation_format(output, output_size, "%.2f", value); } /* WHY: the landing page needs a genuinely global catalogue view, while the @@ -6509,7 +6476,9 @@ static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app, dashboard->weeks[dashboard->week_count - 1U].maxima); baseline = top + height; for (index = 0U; index < dashboard->week_count; ++index) { - const char *label = dashboard->weeks[index].label; + const char *label = index + 1U == dashboard->week_count + ? trainlog_presentation_text("dashboard.current") + : dashboard->weeks[index].label; int label_margin = 3; int x = dashboard->week_count > 1U ? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) / @@ -6561,7 +6530,8 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j", dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j", dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an", - dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"); + trainlog_presentation_source( + dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout")); trainlog_surface_printf(app->content, 3, 2, "Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu", dashboard->completed_session_count, dashboard->performed_set_count, @@ -6597,7 +6567,7 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex if (dashboard->body_count == 1U) trainlog_surface_printf(app->content, 6, right, "%s · %s %s · 1 relevé", - BODY_METRICS[dashboard->body_metric].label, latest_text, + body_metric_label(dashboard->body_metric), latest_text, BODY_METRICS[dashboard->body_metric].unit); else { double delta = latest - dashboard->body[ @@ -6607,7 +6577,7 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex sizeof(delta_text)); trainlog_surface_printf(app->content, 6, right, "%s · %s %s · Δ %c%s", - BODY_METRICS[dashboard->body_metric].label, latest_text, + body_metric_label(dashboard->body_metric), latest_text, BODY_METRICS[dashboard->body_metric].unit, delta >= 0.0 ? '+' : '-', delta_text); app_shell_dashboard_sparkline(app, body_values, @@ -6649,7 +6619,7 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex if (dashboard->body_count == 1U) trainlog_surface_printf(app->content, 8, 4, "%s · %s %s · 1 relevé, tendance indisponible", - BODY_METRICS[dashboard->body_metric].label, latest_text, + body_metric_label(dashboard->body_metric), latest_text, BODY_METRICS[dashboard->body_metric].unit); else { double delta = latest - dashboard->body[dashboard->body_count - 2U].value; @@ -6658,7 +6628,7 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex sizeof(delta_text)); trainlog_surface_printf(app->content, 8, 4, "%s · %s %s · Δ %c%s", - BODY_METRICS[dashboard->body_metric].label, latest_text, + body_metric_label(dashboard->body_metric), latest_text, BODY_METRICS[dashboard->body_metric].unit, delta >= 0.0 ? '+' : '-', delta_text); } @@ -6748,7 +6718,7 @@ static void app_shell_render_body_global(TrainlogAppContext *app) trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%", app->body_global_selected == metric ? '>' : ' ', app->body_global_enabled[metric] ? 'x' : ' ', - BODY_METRICS[metric].label, + body_metric_label(metric), app->body_global_series[metric].latest_percent); trainlog_surface_draw(app->content, row, column + 2, app->body_global_series[metric].symbol); @@ -6950,6 +6920,7 @@ static void app_shell_render_body_analytics(TrainlogAppContext *app) static void app_shell_render_sync(TrainlogAppContext *app) { TrainlogSyncController *sync = &app->sync_controller; + char result[768]; size_t index; int row = 4; if (sync->showing_detail) { @@ -6990,10 +6961,8 @@ static void app_shell_render_sync(TrainlogAppContext *app) return; } if (sync->has_report) { - trainlog_surface_printf(app->content, row++, 2, "%s", - sync->report.success ? sync->report.summary : - sync->report.error[0] != '\0' ? sync->report.error - : "Synchronisation échouée."); + sync_controller_present_result(sync, result, sizeof(result)); + trainlog_surface_printf(app->content, row++, 2, "%s", result); } row += 1; trainlog_surface_printf(app->content, row++, 2, @@ -7004,12 +6973,14 @@ static void app_shell_render_sync(TrainlogAppContext *app) for (index = 0U; index < sync->history_count && row < app->layout.content.height; ++index) { const TrainlogSyncHistoryEntry *entry = &sync->history[index]; - /* CONTRACT: legacy journal directions remain parseable internal - * metadata, but the TUI exposes one PC↔Android sync operation. Old - * directional summaries used the same forbidden mode labels. */ - const char *summary = entry->direction_known && - entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL - ? "entrée historique" : entry->summary; + /* CONTRACT: journal summary bytes stay available to persistence/log + * consumers, but the normal UI exposes only localized typed state. */ + const char *summary = trainlog_presentation_text( + entry->direction_known && + entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL + ? "ui.historical.entry" + : entry->success ? "sync.state.success" + : "sync.state.failure"); trainlog_surface_printf(app->content, row++, 2, "%c %-16s %c %-17.17s %.*s", index == sync->selected ? '>' : ' ', entry->timestamp, entry->success ? '+' : '!', @@ -7022,11 +6993,14 @@ static void app_shell_render_sync(TrainlogAppContext *app) static const char *statistics_window_label(TrainlogStatisticsWindow window) { switch (window) { - case TRAINLOG_STATISTICS_7_DAYS: return "semaine courante"; - case TRAINLOG_STATISTICS_30_DAYS: return "mois courant"; - case TRAINLOG_STATISTICS_ALL: return "Tout l’historique"; + case TRAINLOG_STATISTICS_7_DAYS: + return trainlog_presentation_source("semaine courante"); + case TRAINLOG_STATISTICS_30_DAYS: + return trainlog_presentation_source("mois courant"); + case TRAINLOG_STATISTICS_ALL: + return trainlog_presentation_source("Tout l’historique"); } - return "Période"; + return trainlog_presentation_source("Période"); } static void statistics_duration(char *output, size_t capacity, uint64_t seconds) @@ -7056,7 +7030,8 @@ static void app_shell_render_statistics_hub(TrainlogAppContext *app) TRAINLOG_RGB_BASE, selected ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); trainlog_surface_printf(app->content, 5 + (int)index * 2, 2, - "%s %s", selected ? "▶" : " ", labels[index]); + "%s %s", selected ? "▶" : " ", + trainlog_presentation_source(labels[index])); } trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); @@ -7096,15 +7071,18 @@ static void app_shell_render_statistics_chart(TrainlogAppContext *app, else if (app->statistics_graph == 2U) { metric = "Séries"; unit = "séries"; } else if (app->statistics_graph == 3U) { metric = "Répétitions"; unit = "reps"; } else { metric = "Ressentis immédiats"; unit = "retours"; } - (void)snprintf(title, sizeof(title), "←/→ %s · %s · %zu/5", metric, + metric = trainlog_presentation_source(metric); + unit = trainlog_presentation_source(unit); + (void)trainlog_presentation_format_key(title, sizeof(title), + "chart.statistics", metric, app->statistics_window == TRAINLOG_STATISTICS_7_DAYS - ? "semaines calendaires" + ? trainlog_presentation_source("semaines calendaires") : app->statistics_window == TRAINLOG_STATISTICS_30_DAYS - ? "mois calendaires" + ? trainlog_presentation_source("mois calendaires") : app->statistics.bucket_kind == TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH - ? "mois · historique complet" - : "semaines · historique complet", + ? trainlog_presentation_source("mois · historique complet") + : trainlog_presentation_source("semaines · historique complet"), app->statistics_graph + 1U); options.non_negative = true; options.zero_baseline = true; @@ -7239,14 +7217,19 @@ static void app_shell_render_statistics_training(TrainlogAppContext *app) static const char *exercise_series_label(TrainlogExerciseSeriesKind kind) { switch (kind) { - case TRAINLOG_EXERCISE_SERIES_LOAD: return "Charge observée"; - case TRAINLOG_EXERCISE_SERIES_VOLUME: return "Volume chargé"; - case TRAINLOG_EXERCISE_SERIES_REPETITIONS: return "Répétitions"; - case TRAINLOG_EXERCISE_SERIES_SETS: return "Séries"; - case TRAINLOG_EXERCISE_SERIES_MAX: return "MAX enregistrés"; + case TRAINLOG_EXERCISE_SERIES_LOAD: + return trainlog_presentation_source("Charge observée"); + case TRAINLOG_EXERCISE_SERIES_VOLUME: + return trainlog_presentation_source("Volume chargé"); + case TRAINLOG_EXERCISE_SERIES_REPETITIONS: + return trainlog_presentation_source("Répétitions"); + case TRAINLOG_EXERCISE_SERIES_SETS: + return trainlog_presentation_source("Séries"); + case TRAINLOG_EXERCISE_SERIES_MAX: + return trainlog_presentation_source("MAX enregistrés"); case TRAINLOG_EXERCISE_SERIES_COUNT: break; } - return "Mesure"; + return trainlog_presentation_source("Mesure"); } static const char *exercise_series_unit(TrainlogExerciseSeriesKind kind) @@ -7310,7 +7293,8 @@ static void app_shell_render_exercise_statistics(TrainlogAppContext *app) points[index].value = value->series[kind][index].value; points[index].timestamp_label = value->series[kind][index].timestamp_label; } - (void)snprintf(title, sizeof(title), "←/→ %s · %d/5", + (void)trainlog_presentation_format_key(title, sizeof(title), + "chart.exercise", exercise_series_label(kind), (int)kind + 1); options.non_negative = true; trainlog_chart_render_line(app->content, @@ -7486,7 +7470,7 @@ static void app_shell_render_content(TrainlogAppContext *app) (void)trainlog_feedback_relative_label(app->session_detail.ended_at, app->session_detail.started_at, feedback->observed_at, true, label, sizeof(label)); - trainlog_surface_printf(app->content, row++, 4, "%s %s", label, + trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(label), feedback->raw_text); } } @@ -7525,7 +7509,7 @@ static void app_shell_render_content(TrainlogAppContext *app) else (void)snprintf(metric, sizeof(metric), "%d reps", entry->actual_sets[index].reps); if (entry->actual_sets[index].has_weight) - (void)snprintf(weight, sizeof(weight), "%.2f kg", + (void)trainlog_presentation_format(weight, sizeof(weight), "%.2f kg", entry->actual_sets[index].weight_kg); else (void)snprintf(weight, sizeof(weight), "—"); trainlog_surface_printf(app->content, row++, 4, @@ -7543,7 +7527,7 @@ static void app_shell_render_content(TrainlogAppContext *app) app->session_detail.started_at, app->session_followups[followup_index].observed_at, false, label, sizeof(label)); - trainlog_surface_printf(app->content, row++, 4, "%s %s", label, + trainlog_surface_printf(app->content, row++, 4, "%s %s", trainlog_presentation_source(label), app->session_followups[followup_index].raw_text); } } @@ -7574,8 +7558,11 @@ static void app_shell_render_content(TrainlogAppContext *app) item->equipment_id) != 0) continue; exercise = trainlog_exercise_knowledge_lookup(relation->exercise_id); if (exercise == NULL) continue; - confidence = strcmp(relation->confidence, "high") == 0 ? "élevée" : - strcmp(relation->confidence, "moderate") == 0 ? "modérée" : "incertaine"; + confidence = strcmp(relation->confidence, "high") == 0 + ? trainlog_presentation_text("label.confidence.high") + : strcmp(relation->confidence, "moderate") == 0 + ? trainlog_presentation_text("label.confidence.moderate") + : trainlog_presentation_text("label.confidence.uncertain"); trainlog_surface_printf(app->content, relation_row++, 4, "> %.38s · confiance %s · sources vérifiées", exercise->exercise_name, confidence); @@ -7608,7 +7595,9 @@ static void app_shell_render_content(TrainlogAppContext *app) trainlog_surface_printf(app->content, row++, 2, "Identifiant : %s", item->exercise_id); trainlog_surface_printf(app->content, row++, 2, "Suivi : %s · organisation : %s", item->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée", - item->recording_mode == TRAINLOG_RECORDING_CONTINUOUS ? "continue" : "séries"); + item->recording_mode == TRAINLOG_RECORDING_CONTINUOUS + ? trainlog_presentation_source("continue") + : trainlog_presentation_source("séries")); if (app->exercise_detail_metadata_error) trainlog_surface_printf(app->content, row++, 2, "Métadonnées de zones/usage partiellement indisponibles."); @@ -7808,7 +7797,7 @@ static void app_shell_render_content(TrainlogAppContext *app) } else if (route == TRAINLOG_ROUTE_BODY_METRIC) { int graph_height = app_shell_graph_height(app, 6); trainlog_surface_printf(app->content, 4, 2, "%s — ←/→ change la mesure", - BODY_METRICS[app->body_metric_selected].label); + body_metric_label(app->body_metric_selected)); if (app->body_snapshot_error) trainlog_surface_printf(app->content, 6, 2, "Impossible de lire cet historique."); @@ -7834,8 +7823,10 @@ static void app_shell_render_content(TrainlogAppContext *app) if (route == TRAINLOG_ROUTE_MAX) trainlog_surface_printf(app->content, 2, 2, "Vue : %s · a afficher %s", - app->max_show_all ? "tous les exercices compatibles" : "MAX enregistrés", - app->max_show_all ? "seulement les MAX" : "tous les compatibles"); + trainlog_presentation_source(app->max_show_all + ? "tous les exercices compatibles" : "MAX enregistrés"), + trainlog_presentation_source(app->max_show_all + ? "seulement les MAX" : "tous les compatibles")); trainlog_surface_printf(app->content, 3, 2, "Recherche : %s%s", app->search.bytes > 0U ? app->search.text : "—", app->search.focused ? " [saisie]" : ""); @@ -7862,7 +7853,8 @@ static void app_shell_render_content(TrainlogAppContext *app) "%s %s", date, session_type_history_label(app->sessions[index].session_type)); name = session_line; - (void)snprintf(secondary, sizeof(secondary), "%zu exercice(s)", + (void)trainlog_presentation_format_key(secondary, + sizeof(secondary), "list.exercise.count", app->sessions[index].exercise_count); } else if (route == TRAINLOG_ROUTE_BODY) { char date[9]; @@ -7881,7 +7873,7 @@ static void app_shell_render_content(TrainlogAppContext *app) app->max_list_items[maximum].exercise_id) == 0) { char date[9]; body_short_date(app->max_list_items[maximum].latest_at, date); - (void)snprintf(secondary, sizeof(secondary), + (void)trainlog_presentation_format(secondary, sizeof(secondary), "%.1f kg %s", app->max_list_items[maximum].latest_max_kg, date); measured = true; break; @@ -7897,15 +7889,17 @@ static void app_shell_render_content(TrainlogAppContext *app) char date[9]; body_short_date(app->exercise_list_statistics[fact].latest_at, date); - (void)snprintf(secondary, sizeof(secondary), "%zu fois · %s", - app->exercise_list_statistics[fact].occurrences, date); + (void)trainlog_presentation_format_key(secondary, + sizeof(secondary), "list.occurrences", + app->exercise_list_statistics[fact].occurrences, + date); found = true; break; } if (!found) (void)snprintf(secondary, sizeof(secondary), - "aucune donnée"); + "%s", trainlog_presentation_source("aucune donnée")); } else (void)snprintf(secondary, sizeof(secondary), "%s", app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS - ? "reps" : "durée"); + ? "reps" : trainlog_presentation_source("durée")); } if (label_cells < 12) label_cells = 12; if (label_cells > 48) label_cells = 48; @@ -7932,16 +7926,46 @@ static void app_shell_render_content(TrainlogAppContext *app) app->list.selected_index + 1U, app->loaded_count); } } else if (route == TRAINLOG_ROUTE_SETTINGS) { - trainlog_surface_printf(app->content, 4, 2, "Profil d’estimation corporelle"); + trainlog_surface_set_role(app->content, + app->content_selected == 0U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + app->content_selected == 0U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 4, 2, "%s %s : %s", + app->content_selected == 0U ? ">" : " ", + trainlog_presentation_text("settings.language"), + trainlog_presentation_language_name(trainlog_presentation_language())); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 5, 4, "%s", + trainlog_presentation_text("settings.language.help")); + if (app->settings_language_save_failed) + trainlog_surface_printf(app->content, 6, 4, "%s", + trainlog_presentation_text("settings.language.save_failed")); + else if (app->settings_language_durability_uncertain) + trainlog_surface_printf(app->content, 6, 4, "%s", + trainlog_presentation_text( + "settings.language.durability_uncertain")); + trainlog_surface_set_role(app->content, + app->content_selected == 1U ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + app->content_selected == 1U ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 8, 2, "%s %s", + app->content_selected == 1U ? ">" : " ", + trainlog_presentation_text("settings.body_profile")); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); if (app->body_has_profile) - trainlog_surface_printf(app->content, 6, 2, "%s · %.1f cm", + trainlog_surface_printf(app->content, 10, 4, "%s · %.1f cm", app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE - ? "Formule femme" : "Formule homme", + ? trainlog_presentation_source("Formule femme") + : trainlog_presentation_source("Formule homme"), app->body_profile.height_cm); - else trainlog_surface_printf(app->content, 6, 2, + else trainlog_surface_printf(app->content, 10, 4, "Profil non configuré."); - trainlog_surface_printf(app->content, 8, 2, - "Entrée pour consulter ou modifier le profil."); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 11, 4, "%s", + trainlog_presentation_text("settings.body_profile.help")); } else { trainlog_surface_printf(app->content, 4, 2, "Entrée ouvre la vue complète existante dans ce contexte."); @@ -7972,7 +7996,7 @@ static void app_shell_render_overlay(TrainlogAppContext *app) 3 + (int)index < rect.height; ++index) trainlog_surface_printf(surface, 3 + (int)index, 2, "%c %s", overlay->selected == index ? '>' : ' ', - shell_section_labels[index]); + trainlog_presentation_text(shell_section_labels[index])); } else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) { size_t visible = rect.height > 4 ? (size_t)(rect.height - 4) : 1U; size_t start = overlay->selected >= visible @@ -8031,7 +8055,8 @@ static void app_shell_render_overlay(TrainlogAppContext *app) overlay->selected == 1U ? '>' : ' '); } else { trainlog_surface_printf(surface, 1, 2, "Résultat de la fusion"); - trainlog_surface_printf(surface, 3, 2, "%.64s", controller->message); + trainlog_surface_printf(surface, 3, 2, "%.64s", + trainlog_presentation_source(controller->message)); trainlog_surface_printf(surface, 6, 2, "Entrée fermer"); } } else if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && @@ -8130,8 +8155,8 @@ static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route return; } if (session->form.active && route != previous) { - (void)snprintf(session->message, sizeof(session->message), - "Terminez ou annulez le champ actif avant de changer de rubrique."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.route.active.field"); return; } if (previous == TRAINLOG_ROUTE_SESSION_GENERATOR && @@ -8343,7 +8368,26 @@ static void app_shell_primary(TrainlogAppContext *app) } else if (route == TRAINLOG_ROUTE_SYNC) { sync_controller_open_detail(&app->sync_controller); } else if (route == TRAINLOG_ROUTE_SETTINGS) { - profile_controller_start(app, TRAINLOG_ROUTE_SETTINGS); + if (app->content_selected == 0U) { + TrainlogPresentationLanguage previous = + trainlog_presentation_language(); + TrainlogPresentationLanguage language = + previous == TRAINLOG_PRESENTATION_FRENCH + ? TRAINLOG_PRESENTATION_ENGLISH + : TRAINLOG_PRESENTATION_FRENCH; + TrainlogPresentationSaveResult save_result; + /* WHY: a visual toggle that was not persisted is misleading. + * CONTRACT: switching language mutates only the local preference; + * failure rolls back and produces a localized visible diagnostic. + * INVARIANT: no database object is reachable from this operation. */ + (void)trainlog_presentation_set_language(language); + save_result = trainlog_presentation_save(); + app->settings_language_save_failed = save_result == + TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED; + app->settings_language_durability_uncertain = save_result == + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN; + trainlog_presentation_resolve_save(previous, save_result); + } else profile_controller_start(app, TRAINLOG_ROUTE_SETTINGS); } } @@ -8378,9 +8422,8 @@ static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key) controller->phase = TRAINLOG_EXERCISE_MERGE_CONFIRM; else { controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; - (void)snprintf(controller->message, - sizeof(controller->message), - "Prévisualisation impossible; aucune fusion effectuée."); + (void)trainlog_presentation_format_key(controller->message, + sizeof(controller->message), "msg.merge.preview.fail"); } overlay->selected = 0U; } @@ -8410,20 +8453,19 @@ static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key) sizeof(app->navigation.current.stable_id), controller->merge_target.exercise_id)) { controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; - (void)snprintf(controller->message, - sizeof(controller->message), - "Fusion terminée; identifiant UI hors borne."); + (void)trainlog_presentation_format_key( + controller->message, sizeof(controller->message), + "msg.merge.id.bound"); return; } app_shell_load_exercise_detail_metadata(app); - (void)snprintf(controller->message, sizeof(controller->message), - "Fusion terminée. Cible sélectionnée : %.112s", - controller->merge_target.name); - } else (void)snprintf(controller->message, - sizeof(controller->message), "%s", + (void)trainlog_presentation_format_key( + controller->message, sizeof(controller->message), + "msg.merge.completed", controller->merge_target.name); + } else (void)trainlog_presentation_format_key( + controller->message, sizeof(controller->message), status == TRAINLOG_STATUS_CONFLICT - ? "Fusion refusée : profil ou zone principale incompatible. Aucun changement." - : "Fusion impossible. Aucun changement confirmé."); + ? "msg.merge.conflict" : "msg.merge.fail"); controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; } } @@ -8469,8 +8511,8 @@ static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key) } else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) { (void)app_shell_dispatch_equipment_controller(app, action.key); } else if (app->session.form.active) { - (void)snprintf(app->session.message, sizeof(app->session.message), - "Terminez ou annulez le champ actif avant une autre action."); + (void)trainlog_presentation_format_key(app->session.message, + sizeof(app->session.message), "msg.action.active.field"); } else if (strcmp(action.identifier, "generator.discard") == 0) { session_controller_discard_generator(&app->session); app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); @@ -8571,8 +8613,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME) { if (session->form.bytes == 0U || session->form.bytes >= sizeof(session->pending_equipment.display_name)) { - (void)snprintf(session->message, sizeof(session->message), - "Le nom convivial est requis et doit tenir dans le champ."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.equipment.display.required"); return true; } (void)snprintf(session->pending_equipment.display_name, @@ -8582,8 +8624,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) return true; } else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL) { if (session->form.bytes >= sizeof(session->pending_equipment.label_name)) { - (void)snprintf(session->message, sizeof(session->message), - "Le nom d’étiquette est trop long."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.equipment.label.long"); return true; } (void)snprintf(session->pending_equipment.label_name, @@ -8594,8 +8636,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) } else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE) { if (session->form.bytes == 0U || session->form.bytes >= sizeof(session->pending_equipment.equipment_type)) { - (void)snprintf(session->message, sizeof(session->message), - "Le type d’équipement est requis."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.equipment.type.required"); return true; } (void)snprintf(session->pending_equipment.equipment_type, @@ -8616,8 +8658,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) sizeof(session->pending_equipment.equipment_id), "%s", generated); if (trainlog_database_create_custom_equipment(app->database, &session->pending_equipment) != TRAINLOG_STATUS_OK) { - (void)snprintf(session->message, sizeof(session->message), - "Équipement non créé : nom ou identifiant invalide/conflit."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.equipment.create.fail"); return true; } session->form.active = false; @@ -8645,8 +8687,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) session->selected < session->generated.exercise_count) { if (!session_controller_requalify_generated(app, session->selected, session->pending_generated_sets, integer_value)) { - (void)snprintf(session->message, sizeof(session->message), - "Dose refusée; la proposition précédente est conservée."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.dose.rejected"); return true; } session->form.active = false; @@ -8754,8 +8796,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) draft->input.target_has_weight = false; draft->input.target_weight_kg = 0.0; draft->target_from_percent_max = false; - (void)snprintf(session->message, sizeof(session->message), - "MAX compatible indisponible : exercice et équipement externe exacts requis."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.max.unavailable"); session->form.active = false; session->form_purpose = TRAINLOG_SESSION_FORM_NONE; draft_bind_input(draft); @@ -8772,8 +8814,8 @@ static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) draft->input.set_count = 0U; draft->input.continuous_duration_seconds = 0; } else { - (void)snprintf(session->message, sizeof(session->message), - "Valeur invalide; saisissez un nombre positif."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.value.invalid"); return true; } draft_bind_input(draft); @@ -8910,8 +8952,8 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) draft->input.target_has_weight = false; draft->input.target_weight_kg = 0.0; draft->target_from_percent_max = false; - (void)snprintf(session->message, sizeof(session->message), - "%%MAX indisponible : choisissez une résistance externe compatible."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.percent.unavailable"); } else session_controller_start_form(session, TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX, ""); } else if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') { @@ -8934,8 +8976,8 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) ? draft->input.recording_mode == TRAINLOG_RECORDING_SETS : draft->input.load_mode != TRAINLOG_LOAD_NONE; if (!applicable) { - (void)snprintf(session->message, sizeof(session->message), - "Ce champ ne s’applique pas au profil de cet exercice."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.field.not.applicable"); return true; } if (session->planning_field == 0U) @@ -8947,7 +8989,7 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) else if (session->planning_field == 3U) (void)snprintf(value, sizeof(value), "%d", draft->input.rest_seconds); else if (draft->input.target_has_weight) - (void)snprintf(value, sizeof(value), "%.2f", draft->input.target_weight_kg); + (void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.target_weight_kg); session_controller_start_form(session, purposes[session->planning_field], value); } else if (key == TRAINLOG_KEY_ESCAPE || key == 27 || key == 'b' || key == 'B') session->phase = TRAINLOG_SESSION_DRAFT; @@ -9138,7 +9180,7 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') { char value[48] = ""; if (draft->input.has_max_weight) - (void)snprintf(value, sizeof(value), "%.2f", draft->input.max_weight_kg); + (void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.max_weight_kg); session_controller_start_form(session, TRAINLOG_SESSION_FORM_MAX_WEIGHT, value); } } else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { @@ -9153,7 +9195,7 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) { char value[48] = ""; if (draft->input.continuous_has_speed) - (void)snprintf(value, sizeof(value), "%.2f", + (void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.continuous_speed_kmh); session_controller_start_form(session, TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED, value); @@ -9161,7 +9203,7 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) { char value[48] = ""; if (draft->input.continuous_has_distance) - (void)snprintf(value, sizeof(value), "%.2f", + (void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", draft->input.continuous_distance_km); session_controller_start_form(session, TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE, value); @@ -9188,7 +9230,7 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) char value[48]; session->pending_set = draft->sets[session->set_selected]; if (session->set_field == 1U && draft->input.load_mode != TRAINLOG_LOAD_NONE) { - (void)snprintf(value, sizeof(value), "%.2f", session->pending_set.weight_kg); + (void)trainlog_presentation_format_input(value, sizeof(value), "%.2f", session->pending_set.weight_kg); session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_WEIGHT, value); } else { (void)snprintf(value, sizeof(value), "%d", @@ -9233,8 +9275,8 @@ static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) return true; } if (status != TRAINLOG_STATUS_INVALID_ARGUMENT) - (void)snprintf(session->message, sizeof(session->message), - "Échec de l’enregistrement; le brouillon est conservé."); + (void)trainlog_presentation_format_key(session->message, + sizeof(session->message), "msg.draft.save.fail"); } else if (key == 'q' || key == 'Q') { session->destructive_confirm_selected = false; session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON; @@ -9307,12 +9349,7 @@ static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key) run_status = run(TRAINLOG_SYNC_TRIGGER_TUI, false, TRAINLOG_SYNC_BIDIRECTIONAL, &sync->report); sync->running = false; - /* WHY: retain the engine's precise diagnostic, but never leave a - * completed failed action at the unhelpful `error=unknown` boundary. */ - if ((!sync->report.success || run_status != TRAINLOG_STATUS_OK) && - sync->report.error[0] == '\0') - (void)snprintf(sync->report.error, sizeof(sync->report.error), "%s", - sync_controller_status_error(run_status)); + sync->latest_status = run_status; sync->has_report = true; sync_controller_load_history(sync); (void)memset(&sync->device, 0, sizeof(sync->device)); @@ -9497,6 +9534,11 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) app_shell_load_statistics(app); return; } + if (app->navigation.current.route == TRAINLOG_ROUTE_SETTINGS && + (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { + app->content_selected = app->content_selected == 0U ? 1U : 0U; + return; + } if (app_shell_dispatch_exercise_controller(app, key)) return; if (app_shell_dispatch_equipment_controller(app, key)) return; if (app_shell_dispatch_body_controller(app, key)) return; @@ -9793,8 +9835,8 @@ shell_nonanalytics_input: app->session_detail.session_id); if (app->session.has_draft) { app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT); - (void)snprintf(app->session.message, sizeof(app->session.message), - "Une séance est déjà en cours; elle n’a pas été remplacée."); + (void)trainlog_presentation_format_key(app->session.message, + sizeof(app->session.message), "msg.session.exists"); } else { TrainlogSessionSummary persisted; if (load_persisted_draft(app->database, session_id, &persisted, @@ -9847,6 +9889,11 @@ int trainlog_tui_run(TrainlogDatabase *database) } (void)setlocale(LC_ALL, ""); + /* WHY: presentation preference is loaded before the first surface is + * created so the initial frame cannot flash the default language. + * Invalid/missing local config deterministically remains French. */ + trainlog_presentation_init(); + (void)trainlog_presentation_load(); (void)memset(&app, 0, sizeof(app)); app.database = database; @@ -9890,3 +9937,8 @@ int trainlog_tui_run(TrainlogDatabase *database) trainlog_terminal_destroy(app.terminal); return 0; } + +/* tui.c is included directly by the focused workflow harness; do not rewrite + * the harness's terminal mocks after the production implementation ends. */ +#undef trainlog_surface_printf +#undef trainlog_terminal_printf diff --git a/tui/tests/test_presentation.c b/tui/tests/test_presentation.c new file mode 100644 index 0000000..48b7618 --- /dev/null +++ b/tui/tests/test_presentation.c @@ -0,0 +1,226 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "trainlog/app_shell.h" +#include "trainlog/presentation.h" +#include "trainlog/sync_screen_action.h" + +static void test_default_and_catalog(void) +{ + TrainlogShellLayout french; + TrainlogShellLayout english; + char date[9]; + char dynamic[512]; + trainlog_presentation_init(); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH); + assert(strcmp(trainlog_presentation_text("nav.settings"), "Paramètres") == 0); + assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING), + "Statistiques / Entraînement") == 0); + assert(trainlog_presentation_compact_date("2026-09-15", true, + date, sizeof(date))); + assert(strcmp(date, "15/09/26") == 0); + trainlog_shell_layout_compute(72, 20, &french); + assert(french.usable); + + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + assert(strcmp(trainlog_presentation_text("nav.settings"), "Settings") == 0); + assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING), + "Statistics / Training") == 0); + assert(strcmp(trainlog_sync_screen_confirmation(TRAINLOG_SYNC_BIDIRECTIONAL), + "Synchronize Android and PC in both directions now?") == 0); + assert(strstr(trainlog_sync_screen_footer(100), "Refresh device") != NULL); + assert(strcmp(trainlog_presentation_source("Aucune donnée disponible."), + "No data available.") == 0); + assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic), + "msg.actual.required", "Squat Français") > 0); + assert(strcmp(dynamic, "Enter an actual result for Squat Français.") == 0); + assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic), + "sync.history.legacy", "2026-09-15T10:30:00Z", + trainlog_presentation_text("sync.state.success")) > 0); + assert(strstr(dynamic, "Historical PC↔Android synchronization") != NULL); + assert(strstr(dynamic, "Timestamp: 2026-09-15T10:30:00Z") != NULL); + assert(strstr(dynamic, "Status: successful") != NULL); + /* Representative generator/profile/body/MAX/search/merge/feedback UI + * formats must render English while preserving caller-provided values. */ + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Objectif : %s", "Force") > 0); + assert(strcmp(dynamic, "Goal: Force") == 0); + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "%zu exercice(s) · cible %d min · estimation %d min", (size_t)3, + 45, 42) > 0); + assert(strcmp(dynamic, "3 exercise(s) · target 45 min · estimate 42 min") == 0); + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Tour de taille %.1f cm · variation %+.1f cm", 81.5, -1.0) > 0); + assert(strcmp(dynamic, "Waist circumference 81.5 cm · change -1.0 cm") == 0); + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Historique des MAX") > 0); + assert(strcmp(dynamic, "MAX history") == 0); + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Recherche cible : %s_", "Squat") > 0); + assert(strcmp(dynamic, "Search target: Squat_") == 0); + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Contexte : %s · %s · %zu point%s", "Squat", "MAX", (size_t)1, + "") > 0); + assert(strcmp(dynamic, "Context: Squat · MAX · 1 point") == 0); + assert(trainlog_presentation_compact_date("2026-09-15", true, + date, sizeof(date))); + assert(strcmp(date, "09/15/26") == 0); + /* Persisted and catalog names are not keys and must remain byte-exact. */ + assert(strcmp(trainlog_presentation_source("Développé incliné — Alice"), + "Développé incliné — Alice") == 0); + trainlog_shell_layout_compute(72, 20, &english); + assert(memcmp(&french, &english, sizeof(french)) == 0); + assert(!trainlog_presentation_set_language( + (TrainlogPresentationLanguage)99)); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH)); + /* Decimal localization is scoped to the float conversion: persisted/user + * text is not parsed or mutated merely because it contains digits. */ + assert(trainlog_presentation_format(dynamic, sizeof(dynamic), + "Valeur %.1f · modèle %s", 12.5, "Model 1.2") > 0); + assert(strcmp(dynamic, "Valeur 12,5 · modèle Model 1.2") == 0); + assert(strcmp(trainlog_presentation_source("Pendant la séance"), + "Pendant la séance") == 0); + assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic, + sizeof(dynamic)), "févr. 2026") == 0); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + assert(strcmp(trainlog_presentation_source("Pendant la séance"), + "During session") == 0); + assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic, + sizeof(dynamic)), "Feb 2026") == 0); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH)); + assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic), + "msg.actual.required", "Squat Français") > 0); + assert(strcmp(dynamic, "Saisissez un résultat réel pour Squat Français.") == 0); +} + +static void test_selected_language_owns_decimals(void) +{ + char output[128]; + char *original = setlocale(LC_NUMERIC, NULL); + char saved[128]; + if (original != NULL) (void)snprintf(saved, sizeof(saved), "%s", original); + else saved[0] = '\0'; + + trainlog_presentation_init(); + assert(trainlog_presentation_format(output, sizeof(output), + "Valeur %.2f · delta %+.1f", 12.5, 0.25) > 0); + assert(strcmp(output, "Valeur 12,50 · delta +0,2") == 0); + assert(trainlog_presentation_format(output, sizeof(output), + "%+.1f%%", 3.25) > 0); + assert(strcmp(output, "+3,2%") == 0); + assert(trainlog_presentation_format(output, sizeof(output), + "%2$*3$.*4$f · %1$s · %%", "Model 1.2", 12.5, 7, 2) > 0); + assert(strcmp(output, " 12,50 · Model 1.2 · %") == 0); + assert(trainlog_presentation_format(output, sizeof(output), + "%Lf · %zu", (long double)1.25L, (size_t)4) > 0); + assert(strcmp(output, "1,250000 · 4") == 0); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + assert(trainlog_presentation_format(output, sizeof(output), + "Value %.2f", 12.5) > 0); + assert(strcmp(output, "Value 12.50") == 0); + + /* Contrary-locale integration proof when the host supplies French. The + * assertions above remain the locale-independent fallback proof. */ + if (setlocale(LC_NUMERIC, "fr_FR.UTF-8") != NULL || + setlocale(LC_NUMERIC, "fr_FR.utf8") != NULL) { + assert(trainlog_presentation_format(output, sizeof(output), + "Value %.2f", 12.5) > 0); + assert(strcmp(output, "Value 12.50") == 0); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH)); + assert(trainlog_presentation_format(output, sizeof(output), + "Valeur %.2f", 12.5) > 0); + assert(strcmp(output, "Valeur 12,50") == 0); + } + if (saved[0] != '\0') assert(setlocale(LC_NUMERIC, saved) != NULL); +} + +static void test_persistence(void) +{ + char root_template[] = "/tmp/trainlog-presentation-XXXXXX"; + char *root = mkdtemp(root_template); + char path[4096]; + char directory[4096]; + char invalid[] = "version=1\nlanguage=de\n"; + FILE *file; + assert(root != NULL); + assert(setenv("XDG_CONFIG_HOME", root, 1) == 0); + assert(trainlog_presentation_config_path(path, sizeof(path))); + assert(strstr(path, "/trainlog/presentation.conf") != NULL); + + assert(unsetenv("XDG_CONFIG_HOME") == 0); + assert(setenv("HOME", root, 1) == 0); + assert(trainlog_presentation_config_path(path, sizeof(path))); + assert(strstr(path, "/.config/trainlog/presentation.conf") != NULL); + assert(setenv("XDG_CONFIG_HOME", root, 1) == 0); + assert(trainlog_presentation_config_path(path, sizeof(path))); + + trainlog_presentation_init(); + assert(trainlog_presentation_load()); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + (void)snprintf(directory, sizeof(directory), "%s/trainlog", root); + assert(mkdir(directory, 0700) == 0); + { + char stale[4128]; + FILE *stale_file; + (void)snprintf(stale, sizeof(stale), "%s.tmp.%ld", path, + (long)getpid()); + stale_file = fopen(stale, "wb"); + assert(stale_file != NULL); + assert(fclose(stale_file) == 0); + assert(trainlog_presentation_save() == + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE); + assert(unlink(stale) == 0); + } + trainlog_presentation_init(); + assert(trainlog_presentation_load()); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH); + + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH)); + assert(trainlog_presentation_save() == + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + assert(trainlog_presentation_load()); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH); + + file = fopen(path, "wb"); + assert(file != NULL); + assert(fwrite(invalid, 1U, strlen(invalid), file) == strlen(invalid)); + assert(fclose(file) == 0); + assert(!trainlog_presentation_load()); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH); + + assert(unlink(path) == 0); + (void)snprintf(directory, sizeof(directory), "%s/trainlog", root); + assert(rmdir(directory) == 0); + file = fopen(directory, "wb"); + assert(file != NULL); + assert(fclose(file) == 0); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + assert(trainlog_presentation_save() == + TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED); + trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH, + TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH); + assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH, + TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN); + assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH); + assert(unlink(directory) == 0); + + assert(rmdir(root) == 0); +} + +int main(void) +{ + test_default_and_catalog(); + test_selected_language_owns_decimals(); + test_persistence(); + puts("presentation tests passed"); + return 0; +} diff --git a/tui/tests/test_presentation_catalog.py b/tui/tests/test_presentation_catalog.py new file mode 100644 index 0000000..b9fb19a --- /dev/null +++ b/tui/tests/test_presentation_catalog.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Source-derived, default-fail catalogue audit for active TUI renderers.""" +import pathlib, re, sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] +# WHY: implementation-directory membership is not presentation reachability; +# scanning core SQL, synchronization codecs, and catalogs confuses durable raw +# diagnostics/identifiers with renderer copy. +# CONTRACT: this reviewed allowlist contains every active renderer plus the two +# known presentation-producing helpers. A new renderer must be added here and +# defaults to failure as soon as it contains uncatalogued French copy. +# INVARIANT: database/sync/sync_history/catalog implementations are not UI-copy +# owners and cannot become implicit exceptions through this audit. +RENDERER_SOURCES = ( + "tui/src/tui.c", + "tui/src/chart.c", + "tui/src/app_shell.c", + "tui/src/sync_screen_action.c", +) +PRESENTATION_PRODUCERS = ( + "tui/src/timeutil.c", + "tui/src/statistics.c", +) +SOURCES = RENDERER_SOURCES + PRESENTATION_PRODUCERS +assert not {"tui/src/database.c", "tui/src/sync.c", "tui/src/sync_history.c", + "tui/src/catalog.c"}.intersection(SOURCES) +RENDERER_MARKER = re.compile( + r"trainlog_(?:surface|terminal)_(?:printf|put|draw|set)|" + r"trainlog_presentation_(?:text|source|format)" +) +INFRASTRUCTURE = {"tui/src/presentation.c", "tui/src/terminal.c"} +discovered_renderers = { + str(path.relative_to(ROOT)) + for path in (ROOT / "tui/src").glob("*.c") + if RENDERER_MARKER.search(path.read_text(encoding="utf-8")) +} - INFRASTRUCTURE +unreviewed_renderers = discovered_renderers - set(RENDERER_SOURCES) +if unreviewed_renderers: + sys.stderr.write("UNREVIEWED presentation renderer(s):\n" + + "\n".join(f" {path}" for path in sorted(unreviewed_renderers)) + "\n") + sys.exit(1) +P = (ROOT / "tui/src/presentation.c").read_text(encoding="utf-8") +CATALOG = set(re.findall(r'\{\s*"(?:[^"\\]|\\.)*"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*"', P)) +LIT = re.compile(r'"((?:[^"\\]|\\.)*)"') +FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|entrée|exercice|fusion|historique|impossible|mesure|objectif|profil|recherche|séance|synchron|taille|zone|série|charge)\b', re.I) + +# Exact source-bound exceptions, never pattern exemptions. Each is a +# non-copy token: punctuation/layout, a protocol token, or an internal key. +EXCLUDED = { + "tui/src/tui.c": { + 1083:"—",1495:"— kg",3970:"J−%zu",3973:"M−%zu",3976:"P−%zu",4759:"msg.zone.required",5204:"statistics.zone.previous",5206:"statistics.zone.next",5270:"generator.warning.zone",5612:"—",5655:"— ",5660:"— ",5678:"— ",5693:"—",5696:"—",5820:"—",5823:"—",5826:"—",5881:"—",6312:"%-22s —",6612:"▶",6615:"▶",6643:"▶",6652:"▶",6665:"▶",6668:"▶",6692:"▶",6697:"▶",6888:"%02d/%02d … %02d/%02d",6930:"— ",7040:"PC↔Android",7086:"▶",7310:"%s · %s",7318:"—",7319:"—",7395:"—",7398:"—",7567:"—",7595:"—",7597:"—",7599:"—",7717:"aucun",7719:"aucun",7884:"—",7935:"—",8065:" · ↑",8066:" · ↓"}, + "tui/src/chart.c": {254:"◆ %.2f %s"}, + "tui/src/app_shell.c": {317:"…"}, + "tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"}, + "tui/src/statistics.c": {230:("août", "déc.")}, +} + +def candidates(text, path): + """Independent source scanner: adding uncatalogued French copy fails.""" + out=[] + for line_no,line in enumerate(text.splitlines(),1): + for match in LIT.finditer(line): + value=match.group(1) + if FR.search(value): out.append((path,line_no,value)) + return out + +# Non-tautology proof: this never appears in the catalogue/manifest. +probe=candidates('trainlog_surface_printf(s, 1, 1, "Bonjour séance");','fixture.c') +assert probe == [('fixture.c',1,'Bonjour séance')] +assert probe[0][2] not in CATALOG +assert RENDERER_MARKER.search('trainlog_surface_printf(s, 1, 1, "new copy")') + +unknown=[]; used=set() +for path in SOURCES: + for candidate in candidates((ROOT/path).read_text(encoding='utf-8'),path): + _,line,value=candidate + if value in CATALOG: continue + allowed = EXCLUDED.get(path,{}).get(line) + if allowed == value or isinstance(allowed, tuple) and value in allowed: + used.add(candidate); continue + unknown.append(candidate) +if unknown: + for kind, rows in (("TRANSLATABLE_UI catalogue omissions",unknown),): + if rows: sys.stderr.write(kind+':\n'+'\n'.join(' %s:%s: %s'%r for r in rows)+'\n') + sys.exit(1) +print('TRANSLATABLE_UI=0 unexplained; source-derived exclusions:',len(used)) diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index c8062f5..beadc4e 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -835,7 +835,9 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger, sync_last_direction = direction; CHECK(sync_running_app != NULL && sync_running_app->sync_controller.running); CHECK(strstr(sync_running_app->terminal->output, - "Synchronisation en cours") != NULL); + "Synchronisation en cours") != NULL || + strstr(sync_running_app->terminal->output, + "Synchronization in progress") != NULL); /* A re-entrant launch attempt is consumed while the sole run is active. */ CHECK(app_shell_dispatch_sync(sync_running_app, 's')); CHECK(app_shell_dispatch_sync(sync_running_app, TRAINLOG_KEY_ENTER)); @@ -843,13 +845,23 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger, (void)memset(output, 0, sizeof(*output)); output->success = run_number != 2; output->direction = direction; + output->exercises_imported = 2U; + output->exercises_reconciled = 3U; + output->exercises_skipped = 4U; + output->sessions_imported = 5U; + output->sessions_skipped = 6U; + output->body_imported = 7U; + output->body_skipped = 8U; + output->equipment_definitions_imported = 9U; + output->equipment_definitions_skipped = 10U; + output->catalog_published = 11U; (void)snprintf(output->sync_id, sizeof(output->sync_id), "sy_00000000-0000-4000-8000-%012d", run_number); (void)snprintf(output->summary, sizeof(output->summary), "%s", - output->success ? "sync test réussie" : "sync test échouée"); + output->success ? "RÉSUMÉ BRUT succès" : "RÉSUMÉ BRUT échec"); if (!output->success) (void)snprintf(output->error, sizeof(output->error), "%s", - "Appareil déconnecté pendant la synchronisation."); + "DIAGNOSTIC BRUT appareil déconnecté."); CHECK(snprintf(directory, sizeof(directory), "%s/trainlog", sync_journal_root) > 0); CHECK(mkdir(directory, 0700) == 0 || errno == EEXIST); @@ -864,6 +876,53 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger, return output->success ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR; } +static bool test_sync_result_presenter_maps_typed_statuses_in_both_languages(void) +{ + static const struct { + TrainlogStatus status; + const char *fr; + const char *en; + } cases[] = { + {TRAINLOG_STATUS_CONFLICT, "une autre exécution est active", + "another run is active"}, + {TRAINLOG_STATUS_NOT_FOUND, "aucun appareil MTP Trainlog détecté", + "no Trainlog MTP device detected"}, + {TRAINLOG_STATUS_INVALID_ARGUMENT, "données ou demande invalides", + "invalid data or request"}, + {TRAINLOG_STATUS_DATABASE_ERROR, "erreur de base de données locale", + "local database error"}, + {TRAINLOG_STATUS_SCHEMA_UNSUPPORTED, "version de données non prise en charge", + "unsupported data version"}, + {TRAINLOG_STATUS_SYSTEM_ERROR, "erreur d’accès appareil ou fichier", + "device or file access error"}, + {TRAINLOG_STATUS_OK, "sans rapport de réussite", + "without a success report"}, + }; + TrainlogSyncController sync; + char presented[768]; + size_t index; + (void)memset(&sync, 0, sizeof(sync)); + (void)snprintf(sync.report.summary, sizeof(sync.report.summary), + "%s", "RÉSUMÉ BRUT interdit"); + (void)snprintf(sync.report.error, sizeof(sync.report.error), + "%s", "DIAGNOSTIC BRUT interdit"); + for (index = 0U; index < sizeof(cases) / sizeof(cases[0]); ++index) { + sync.latest_status = cases[index].status; + trainlog_presentation_init(); + sync_controller_present_result(&sync, presented, sizeof(presented)); + CHECK(strstr(presented, cases[index].fr) != NULL); + CHECK(strstr(presented, "BRUT") == NULL); + CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + sync_controller_present_result(&sync, presented, sizeof(presented)); + CHECK(strstr(presented, cases[index].en) != NULL); + CHECK(strstr(presented, "BRUT") == NULL); + } + CHECK(strcmp(sync.report.summary, "RÉSUMÉ BRUT interdit") == 0); + CHECK(strcmp(sync.report.error, "DIAGNOSTIC BRUT interdit") == 0); + trainlog_presentation_init(); + return true; +} + static bool test_shell_body_controller_preserves_identity_and_failed_edit(void) { TrainlogDatabase *database = NULL; @@ -993,7 +1052,18 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void) CHECK(app.sync_controller.history_count == 1U); terminal.output_used = 0U; terminal.output[0] = '\0'; app_shell_render_sync(&app); - CHECK(strstr(terminal.output, "sync test réussie") != NULL); + CHECK(strstr(terminal.output, "Synchronisation réussie") != NULL); + CHECK(strstr(terminal.output, "exercices +2, réconciliés 3, présents 4") != NULL); + CHECK(strstr(terminal.output, "catalogue publié 11") != NULL); + CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL); + CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT succès") == 0); + CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH)); + terminal.output_used = 0U; terminal.output[0] = '\0'; + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, "Synchronization successful") != NULL); + CHECK(strstr(terminal.output, "exercises +2, reconciled 3, present 4") != NULL); + CHECK(strstr(terminal.output, "catalog published 11") != NULL); + CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL); /* Repeating the action creates the next legitimate journal event. */ app_shell_dispatch(&app, 's'); @@ -1002,11 +1072,17 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void) CHECK(sync_run_calls == 2); CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL); CHECK(!app.sync_controller.report.success); + CHECK(app.sync_controller.latest_status == TRAINLOG_STATUS_SYSTEM_ERROR); CHECK(app.sync_controller.history_count == 2U); terminal.output_used = 0U; terminal.output[0] = '\0'; app_shell_render_sync(&app); CHECK(strstr(terminal.output, - "Appareil déconnecté pendant la synchronisation.") != NULL); + "Synchronization stopped: device or file access error.") != NULL); + CHECK(strstr(terminal.output, "DIAGNOSTIC BRUT") == NULL); + CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL); + CHECK(strcmp(app.sync_controller.report.error, + "DIAGNOSTIC BRUT appareil déconnecté.") == 0); + CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT échec") == 0); CHECK(app_shell_dispatch_sync(&app, 'r')); CHECK(sync_probe_calls == 3 && sync_run_calls == 2); if (saved_data_home != NULL) { @@ -1025,6 +1101,7 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void) CHECK(rmdir(temporary) == 0); } sync_running_app = NULL; + trainlog_presentation_init(); trainlog_database_close(database); return true; } @@ -1110,6 +1187,21 @@ static bool test_shell_sync_history_neutralizes_legacy_directions(void) app.sync_controller.showing_detail = false; } + trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH); + app.sync_controller.selected = 0U; + terminal.output_used = 0U; + terminal.output[0] = '\0'; + CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER)); + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, + "Historical PC↔Android synchronization") != NULL); + CHECK(strstr(terminal.output, "Timestamp:") != NULL); + CHECK(strstr(terminal.output, "Status:") != NULL); + CHECK(strstr(terminal.output, "successful") != NULL || + strstr(terminal.output, "failed") != NULL); + CHECK(strstr(terminal.output, "Horodatage") == NULL); + trainlog_presentation_init(); + if (saved_data_home != NULL) { CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0); free(saved_data_home); @@ -2223,11 +2315,11 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void) dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); app_shell_render_body_profile(&app); - CHECK(strstr(terminal.output, "Poids : 80.00 kg") != NULL); + CHECK(strstr(terminal.output, "Poids : 80,00 kg") != NULL); CHECK(strstr(terminal.output, "Poitrine") != NULL); - CHECK(strstr(terminal.output, "100.0 cm") != NULL); + CHECK(strstr(terminal.output, "100,0 cm") != NULL); CHECK(strstr(terminal.output, "Tour de taille") != NULL); - CHECK(strstr(terminal.output, "85.0 cm") != NULL); + CHECK(strstr(terminal.output, "85,0 cm") != NULL); CHECK(strstr(terminal.output, "Épaules") == NULL); CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0); CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0); @@ -2257,9 +2349,9 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void) CHECK(app.list.selected_index == 1U); dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); app_shell_render_body_profile(&app); - CHECK(strstr(terminal.output, "Poids : 78.00 kg") != NULL); - CHECK(strstr(terminal.output, "90.0 cm") != NULL); - CHECK(strstr(terminal.output, "100.0 cm") == NULL); + CHECK(strstr(terminal.output, "Poids : 78,00 kg") != NULL); + CHECK(strstr(terminal.output, "90,0 cm") != NULL); + CHECK(strstr(terminal.output, "100,0 cm") == NULL); app.body_metric_selected = 0U; app_shell_move_available_body_metric(&app, 1); @@ -2305,7 +2397,7 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void) app_shell_render_statistics_training(&app); CHECK(strstr(terminal.output, "ACTIVITÉ") != NULL); CHECK(strstr(terminal.output, "TRAVAIL") != NULL); - CHECK(strstr(terminal.output, "Volume 3200.0 kg") != NULL); + CHECK(strstr(terminal.output, "Volume 3200,0 kg") != NULL); CHECK(strstr(terminal.output, "Volume chargé · mois calendaires") != NULL); CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); @@ -2366,6 +2458,14 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void) CHECK(strstr(terminal.output, "Développé couché") != NULL); CHECK(strstr(terminal.output, "Charge observée") != NULL); CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH); + terminal.output_used = 0U; + terminal.output[0] = '\0'; + app_shell_render_exercise_statistics(&app); + CHECK(strstr(terminal.output, "Développé couché") != NULL); + CHECK(strstr(terminal.output, "Observed load") != NULL); + CHECK(strstr(terminal.output, "Charge observée") == NULL); + trainlog_presentation_init(); return true; } @@ -2386,6 +2486,7 @@ int main(void) !test_shell_compact_navigation_focus_contract() || !test_shell_detail_back_restores_stable_list_selection() || !test_shell_body_controller_preserves_identity_and_failed_edit() || + !test_sync_result_presenter_maps_typed_statuses_in_both_languages() || !test_shell_sync_f7_runs_bidirectional_and_refreshes_history() || !test_shell_sync_history_neutralizes_legacy_directions() || !test_equipment_collector_finds_match_after_first_pages() ||