From b2249b10b1290b6b122dc84b38d4d5a93d105837 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 11 Sep 2026 14:16:31 +0200 Subject: [PATCH] feat(exercises): add canonical naming and finalize app shell --- CHANGELOG.md | 4 + README.md | 4 + .../trainlog/ui/DraftUiTestActivity.kt | 1 - .../trainlog/data/ExerciseNameCatalog.kt | 61 ++++++++++++ .../trainlog/data/TrainlogRepository.kt | 13 ++- .../com/labfytools/trainlog/ui/HomeScreen.kt | 5 +- .../labfytools/trainlog/ui/SectionScreens.kt | 2 - .../com/labfytools/trainlog/ui/TrainlogApp.kt | 4 +- .../data/TrainlogRepositoryDraftTest.kt | 43 +++++++++ catalog/exercise-names-v1.json | 22 +++++ docs/android.md | 4 + docs/architecture.md | 5 + docs/current_state.md | 8 ++ docs/database.md | 4 + docs/exercise_data_model.md | 4 + docs/reviews/exercise_naming_v1_audit.md | 37 ++++++++ docs/reviews/stats_v1_design.md | 30 ++++++ docs/roadmap.md | 11 +++ docs/sync_exchange.md | 4 + docs/tests.md | 4 + docs/tui.md | 4 + tests/test_exercise_reconciliation.py | 92 +++++++++++++++++++ tools/exercise_names.py | 83 +++++++++++++++++ tools/export_pc_catalog.py | 5 +- tools/export_pc_mobile.py | 9 +- tools/import_mobile_export.py | 11 ++- tools/validate_json.py | 19 ++++ tui/include/trainlog/terminal.h | 10 +- tui/src/terminal.c | 21 +++-- tui/src/tui.c | 43 ++++----- tui/tests/test_terminal_input.c | 41 ++++++--- 31 files changed, 543 insertions(+), 65 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/ExerciseNameCatalog.kt create mode 100644 catalog/exercise-names-v1.json create mode 100644 docs/reviews/exercise_naming_v1_audit.md create mode 100644 docs/reviews/stats_v1_design.md create mode 100644 tools/exercise_names.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a03115..950eb07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -338,3 +338,7 @@ Added: Estimated analytics remain derived display values and are never persisted as direct measurements. Android remains capture-only for this feature. + +- Hid `SESSION_GENERATOR_V1` from normal UI pending V2; manual `%MAX` entry is unchanged. +- Added versioned canonical exercise display names, stale-peer-name convergence, + and the STATS_V1 query-contract preparation (no analytics implementation). diff --git a/README.md b/README.md index 5d0424f..a3a7a51 100644 --- a/README.md +++ b/README.md @@ -346,3 +346,7 @@ BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS DESKTOP_TESTS=39/39 PASS ``` + +`SESSION_GENERATOR_V1` remains implemented but is hidden from normal Android +and TUI navigation pending V2. `catalog/exercise-names-v1.json` owns mapped +exercise display metadata across sync while preserving stable IDs and history. diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt index bb4a468..f66a1c2 100644 --- a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -98,7 +98,6 @@ class DraftUiTestActivity : ComponentActivity() { onBody = {}, onOpenLatestSession = {}, onSync = {}, - onGenerateSession = {}, ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/ExerciseNameCatalog.kt b/android/app/src/main/java/com/labfytools/trainlog/data/ExerciseNameCatalog.kt new file mode 100644 index 0000000..7fe831d --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/ExerciseNameCatalog.kt @@ -0,0 +1,61 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import org.json.JSONObject +import java.text.Normalizer +import java.util.Locale +import java.util.UUID + +/** Strict shared owner map for presentation names of known stable exercise IDs. */ +object ExerciseNameCatalog { + private const val ASSET_NAME = "exercise-names-v1.json" + + fun load(context: Context): Map { + val root = context.assets.open(ASSET_NAME).bufferedReader().use { + JSONObject(it.readText()) + } + check(root.keys().asSequence().toSet() == setOf("format", "version", "names")) + check(root.getString("format") == "trainlog-exercise-names-v1") + check(root.getInt("version") == 1) + + val names = root.getJSONArray("names") + val result = linkedMapOf() + val canonicalOwners = mutableSetOf() + val previousOwners = mutableSetOf() + for (index in 0 until names.length()) { + val item = names.getJSONObject(index) + check(item.keys().asSequence().toSet() == setOf( + "exercise_id", "display_name", "normalized_name", "previous_display_names", + )) + val exerciseId = item.getString("exercise_id") + check(exerciseId.startsWith("ex_")) + val uuid = UUID.fromString(exerciseId.removePrefix("ex_")) + check(uuid.version() == 4 && "ex_$uuid" == exerciseId) + val displayName = item.getString("display_name") + val normalizedName = normalizeManifestName(displayName) + check(displayName == displayName.trim() && item.getString("normalized_name") == normalizedName) + check(result.put(exerciseId, displayName) == null) + check(canonicalOwners.add(normalizedName)) + + val previous = item.getJSONArray("previous_display_names") + check(previous.length() > 0) + val localPrevious = mutableSetOf() + for (previousIndex in 0 until previous.length()) { + val oldName = previous.getString(previousIndex) + val oldNormalized = normalizeManifestName(oldName) + check(oldName == oldName.trim() && oldNormalized != normalizedName) + check(localPrevious.add(oldNormalized) && previousOwners.add(oldNormalized)) + } + } + check(canonicalOwners.intersect(previousOwners).isEmpty()) + return result + } + + private fun normalizeManifestName(value: String): String { + val normalized = Normalizer.normalize(value.lowercase(Locale.ROOT), Normalizer.Form.NFC) + .replace(Regex("\\s+"), " ") + .trim() + check(normalized.isNotEmpty()) + return normalized + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index af00982..ccf5d33 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -274,6 +274,7 @@ class TrainlogRepository( ANDROID_DATABASE_NAME, ) { private val applicationContext = context.applicationContext + private val canonicalExerciseNames = ExerciseNameCatalog.load(applicationContext) private val bodyZones = BodyZoneCatalog.load(applicationContext) private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones) private val sessionGenerationPolicy = @@ -1534,10 +1535,8 @@ class TrainlogRepository( val exerciseId = resolveExerciseId(db, suppliedExerciseId) val suppliedRetiredAlias = suppliedExerciseId != exerciseId - val name = - item.getString( - "name" - ) + val suppliedName = item.getString("name") + val name = canonicalExerciseNames[exerciseId] ?: suppliedName val normalized = normalizeName( @@ -1600,6 +1599,12 @@ class TrainlogRepository( } if ( + !NewExerciseProfile( + name = suppliedName, + recordingMode = recording, + trackingMode = tracking, + dataFields = dataFields, + ).validate() || !NewExerciseProfile( name = name, recordingMode = recording, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 9cd2b72..59f9bf5 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -14,7 +14,6 @@ fun HomeScreen( latestSession: SessionSummary?, latestMaximum: LatestExerciseMax?, onSession: () -> Unit, - onGenerateSession: () -> Unit, onBody: () -> Unit, onOpenLatestSession: (String) -> Unit, onSync: () -> Unit, @@ -26,10 +25,8 @@ fun HomeScreen( if (activeDraft != null) { val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement" TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession) - TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession) } else { - TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession) - TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession) + TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession) } } latestSession?.let { session -> diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 580e665..0204d2e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -20,7 +20,6 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors fun SessionsHub( draft: ActiveSessionDraft?, onResume: () -> Unit, - onGenerate: () -> Unit, onManual: () -> Unit, onHistory: () -> Unit, ) { @@ -31,7 +30,6 @@ fun SessionsHub( else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success) } TrainlogFrame("Préparer") { - TrainlogAction("Programmer une séance", "Générer une proposition modifiable.", onGenerate, accent = if (draft == null) colors.success else colors.accent) TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual) } TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index e0e1142..6aa6004 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -76,10 +76,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S AppRoute.Home -> HomeScreen( activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning, repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(), - ::openManualSession, { open(AppRoute.SessionGenerator) }, + ::openManualSession, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, ) - AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, { open(AppRoute.SessionGenerator) }, ::openManualSession, { open(AppRoute.CompletedSessions) }) + AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, ::openManualSession, { open(AppRoute.CompletedSessions) }) AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ }) AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, { draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée." diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 8932017..ef8209d 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -590,6 +590,49 @@ class TrainlogRepositoryDraftTest { assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId }) } + @Test + fun pcCatalogUsesCanonicalMappedNameButLeavesDistinctAndCustomIdsAlone() { + val repo = openRepository() + val mappedId = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163" + val distinctId = "ex_617007f9-7420-4408-91b9-8ffb77900f13" + val customId = "ex_fcc75fa7-671e-4868-bab2-47033128dfb7" + fun item(id: String, name: String) = JSONObject() + .put("exercise_id", id) + .put("name", name) + .put("recording_mode", "sets") + .put("tracking_mode", "reps") + .put("data_fields", 0) + val stalePcCatalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put( + "exercises", + org.json.JSONArray() + .put(item(mappedId, "Seated leg curl")) + .put(item(distinctId, "Seated Leg")) + .put(item(customId, "My custom curl")), + ) + + assertEquals( + PcCatalogImportResult.Applied(imported = 3, reconciled = 0, skipped = 0), + repo.applyPcCatalogJson(stalePcCatalog.toString()), + ) + val byId = repo.listExercises().associateBy { it.exerciseId } + assertEquals("Flexion de genou assise", byId.getValue(mappedId).name) + assertEquals("Seated Leg", byId.getValue(distinctId).name) + assertEquals("My custom curl", byId.getValue(customId).name) + + /* Replaying the stale mapped label is idempotent and cannot restore it. */ + assertEquals( + PcCatalogImportResult.Applied(imported = 0, reconciled = 0, skipped = 3), + repo.applyPcCatalogJson(stalePcCatalog.toString()), + ) + assertEquals( + "Flexion de genou assise", + repo.listExercises().single { it.exerciseId == mappedId }.name, + ) + } + @Test fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() { val repo = openRepository() diff --git a/catalog/exercise-names-v1.json b/catalog/exercise-names-v1.json new file mode 100644 index 0000000..6f4c69a --- /dev/null +++ b/catalog/exercise-names-v1.json @@ -0,0 +1,22 @@ +{ + "format": "trainlog-exercise-names-v1", + "version": 1, + "names": [ + {"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","display_name":"Adduction de hanche assise","normalized_name":"adduction de hanche assise","previous_display_names":["Hip adduction"]}, + {"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Extension de genou assise","normalized_name":"extension de genou assise","previous_display_names":["Leg extension"]}, + {"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotation du tronc à la machine","normalized_name":"rotation du tronc à la machine","previous_display_names":["Rotary torso"]}, + {"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Tirage horizontal divergent assis","normalized_name":"tirage horizontal divergent assis","previous_display_names":["Diverged seated row"]}, + {"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Tirage horizontal assis","normalized_name":"tirage horizontal assis","previous_display_names":["Seated row"]}, + {"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Extension du tronc","normalized_name":"extension du tronc","previous_display_names":["Back extension"]}, + {"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Écarté inversé à la machine","normalized_name":"écarté inversé à la machine","previous_display_names":["Rear Delt"]}, + {"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Développé épaules convergent","normalized_name":"développé épaules convergent","previous_display_names":["Converging Shoulder Press"]}, + {"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Abduction de hanche assise","normalized_name":"abduction de hanche assise","previous_display_names":["Hip abduction"]}, + {"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","display_name":"Crunch à la machine","normalized_name":"crunch à la machine","previous_display_names":["Abdominal crunch"]}, + {"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Flexion de genou assise","normalized_name":"flexion de genou assise","previous_display_names":["Seated leg curl"]}, + {"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Tirage vertical à la poulie","normalized_name":"tirage vertical à la poulie","previous_display_names":["Lat pull"]}, + {"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Presse à cuisses","normalized_name":"presse à cuisses","previous_display_names":["Leg press"]}, + {"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Tirage vertical divergent","normalized_name":"tirage vertical divergent","previous_display_names":["Diverging lat pulldown"]}, + {"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Flexion de genou couchée","normalized_name":"flexion de genou couchée","previous_display_names":["Prone leg curl"]}, + {"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Flexion de coude à la machine","normalized_name":"flexion de coude à la machine","previous_display_names":["Arm curl"]} + ] +} diff --git a/docs/android.md b/docs/android.md index cb091ee..8b8ecba 100644 --- a/docs/android.md +++ b/docs/android.md @@ -637,3 +637,7 @@ The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonic and equipment identity syntax, bidirectional exercise/capability compatibility, HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its full source and uncertainty contract is in [Training knowledge system V1](domain/knowledge_system.md). + +The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending +V2. When importing the PC catalog, Android applies the versioned canonical +exercise-name mapping for mapped IDs; unmapped user exercises remain editable. diff --git a/docs/architecture.md b/docs/architecture.md index d3bd956..5ae3759 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -567,3 +567,8 @@ fails explicitly. The initial full-tranche audit's stale temporal-documentation, Android loader, Meson input, and C role-only query findings were resolved by one bounded repair chain and independently verified. Its full contract, uncertainty boundary and future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md). + +Canonical mapped exercise names are a separate, small versioned presentation +catalog. Stable `exercise_id` owns both display and normalized metadata for +mapped IDs during PC/Android reconciliation; it does not alter observations, +aliases, BODY ZONES, or equipment context. diff --git a/docs/current_state.md b/docs/current_state.md index 5c8139f..e232d51 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -487,3 +487,11 @@ needed for the optional body-fat estimate. No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored as if it were a real measurement. + +## Shell and naming closeout + +`SESSION_GENERATOR_V1` is technically retained but hidden from normal UI pending +V2. Manual `%MAX` remains available. `exercise-names-v1.json` supplies +movement-oriented canonical metadata for mapped stable IDs; stale peer labels +cannot overwrite those names, while custom exercise naming remains editable. +STATS_V1 is prepared only in `reviews/stats_v1_design.md`. diff --git a/docs/database.md b/docs/database.md index c01aea4..a952df8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -618,3 +618,7 @@ The temporal contract has independent PASS evidence. The initial audit's stale temporal documentation, Android loader, Meson input, and C role-only query findings were repaired, independently verified, and final-validated; `TRAINING_KNOWLEDGE_V1=PASS`. + +Exercise naming changes update only `exercises.name` and `normalized_name` for +explicitly mapped stable IDs; no schema migration or historical-row rewrite is +required. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 4067e3f..d9e7179 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -352,3 +352,7 @@ the same comparison; malformed cursors or matching stored timestamps report explicit errors. The initial audit's stale temporal documentation, Android loader, Meson input, and C role-only query findings were repaired and independently verified; final validation passed. + +Exercise names are mutable display metadata. The versioned canonical-name map +applies only to listed stable IDs and never makes two distinct exercises, +equipment contexts, MAX observations, or historical occurrences equivalent. diff --git a/docs/reviews/exercise_naming_v1_audit.md b/docs/reviews/exercise_naming_v1_audit.md new file mode 100644 index 0000000..1d894b8 --- /dev/null +++ b/docs/reviews/exercise_naming_v1_audit.md @@ -0,0 +1,37 @@ +# EXERCISE_NAMING_V1 audit + +The mapping in `catalog/exercise-names-v1.json` is a presentation-metadata +mapping keyed solely by stable `exercise_id`. It does not create IDs, merge +rows, or rewrite session observations. Its evidence is the resolved movement +interpretation in `catalog/exercise-knowledge-v1.json`. + +| id | current | proposed | status / confidence | action | +| --- | --- | --- | --- | --- | +| ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef | Hip adduction | Adduction de hanche assise | resolved/high | SAFE_RENAME | +| ex_1872246a-39ae-44dc-b58d-f87e90ca49ab | Leg extension | Extension de genou assise | resolved/high | SAFE_RENAME | +| ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0 | Rotary torso | Rotation du tronc à la machine | resolved/moderate | SAFE_RENAME | +| ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de | Diverged seated row | Tirage horizontal divergent assis | resolved/moderate | SAFE_RENAME | +| ex_33f79331-871c-4eed-babe-346e53a99070 | Seated row | Tirage horizontal assis | resolved/moderate | SAFE_RENAME | +| ex_474ec393-3efa-4aaa-8e08-1a0245ed7835 | Back extension | Extension du tronc | resolved/moderate | SAFE_RENAME | +| ex_4cd2433e-80b1-478a-b8df-73fc6ef80962 | Rear Delt | Écarté inversé à la machine | resolved/moderate | SAFE_RENAME | +| ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b | Converging Shoulder Press | Développé épaules convergent | resolved/moderate | SAFE_RENAME | +| ex_7e7cf906-2214-4066-bcb7-c16382d83b3b | Hip abduction | Abduction de hanche assise | resolved/moderate | SAFE_RENAME | +| ex_9adf7566-f10c-443c-b63b-681d37665693 | Abdominal crunch | Crunch à la machine | resolved/moderate | SAFE_RENAME | +| ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163 | Seated leg curl | Flexion de genou assise | resolved/high | SAFE_RENAME | +| ex_a72fa713-4b0e-431d-95e2-42d95beb77b1 | Lat pull | Tirage vertical à la poulie | resolved/moderate | SAFE_RENAME | +| ex_b432623f-bfe9-4daf-a653-60ec7fdffbde | Leg press | Presse à cuisses | resolved/moderate | SAFE_RENAME | +| ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6 | Diverging lat pulldown | Tirage vertical divergent | resolved/moderate | SAFE_RENAME | +| ex_d7398d9f-d928-4d2e-94e9-74e201da55c5 | Prone leg curl | Flexion de genou couchée | resolved/high | SAFE_RENAME | +| ex_ec619fc2-4685-4044-873c-86764bd4a0fe | Arm curl | Flexion de coude à la machine | resolved/high | SAFE_RENAME | + +`Abdominal`, `Chest press`, and `Converting chest press` are conditional with +uncertain identity and are intentionally unchanged. `Gym échauffement`, +`Marche`, `Planche`, and custom floor variants are human-readable or have no +resolved catalog mapping and remain unchanged. The user explicitly confirmed +that `Seated Leg` (`ex_617007f9-7420-4408-91b9-8ffb77900f13`) and `Seated leg +curl` (`ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163`) are distinct exercises on +different physical machines. They are **NOT_DUPLICATE**: no merge, shared MAX, +shared performance context, or history collapse is permitted. `Seated Leg` +therefore remains unchanged until its exact identity has independent evidence; +only the independently named `Seated leg curl` receives the resolved seated +knee-flexion label. diff --git a/docs/reviews/stats_v1_design.md b/docs/reviews/stats_v1_design.md new file mode 100644 index 0000000..9e4a03a --- /dev/null +++ b/docs/reviews/stats_v1_design.md @@ -0,0 +1,30 @@ +# STATS_V1 preparation + +STATS_V1 is not implemented and needs no schema change. Queries read completed +sessions only: `sessions.ended_at IS NOT NULL`, `session_exercises`, +`performed_sets`, `continuous_activity`, `max_results`, `exercise_body_zones`, +and `body_observations`. Drafts and planned targets are never frequency or +volume facts. + +Proposed query boundary: date-windowed session count; completed exercise +occurrence count; performed-set count; continuous duration; per-exercise +working-weight observations; explicit MAX chronology; body-observation series; +and BODY ZONE/movement-pattern projections through the read-only knowledge +catalog. Windows use local calendar week/month derived from RFC3339 timestamps; +missing actual sets, duration, equipment, or load are omitted from that metric, +never coerced to zero. + +Comparisons group first by `exercise_id`, then equipment context and load mode. +External resistance on different equipment IDs is not comparable; assistance is +not external resistance. A trend may label equipment-specific series but must +not blend them. Body-zone frequency counts completed occurrences/sets through +the existing primary/secondary hierarchy without double-counting parent and +child zones. Movement patterns are read-only knowledge projections; unresolved +exercises remain unclassified. + +Android starts with overview, frequency, progress, exercise, zones, +mensurations and MAX cards; TUI uses the same query contract with tables and +small text sparklines. Validate empty windows, DST/date boundaries, duplicate +aliases, heterogeneous sets, continuous-only records, NULL equipment, planned +but unperformed targets, and fixture parity. Caches are future-only and require +an explicit schema tranche. diff --git a/docs/roadmap.md b/docs/roadmap.md index f697ac3..5c4e88a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -436,3 +436,14 @@ assistance interpreted as external load body-composition estimates presented as direct measurements incompatible changes to Trainlog JSON v1 without a new format version ``` + +## Session generator V2 + +V1 remains technically implemented but hidden from normal UI. V2 must construct +the requested duration with warm-up, appropriate ramp-up, coherent main work, +cool-down, transition/setup time, and explicit candidate-scarcity handling. + +## STATS_V1 + +Prepared only: see `reviews/stats_v1_design.md`. Implementation remains a +future derived-query tranche with no new summary schema in this checkpoint. diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 66ab715..010a144 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -651,3 +651,7 @@ The historical V1 fallback never consumes a V2 equipment companion: V1 carries no occurrence-level equipment signal, so a neighboring V2 companion belongs to a different generation and is ignored. This preserves rather than clears existing equipment. + +Mapped canonical exercise names are metadata owned by `exercise_id`: PC exports +them and both import paths prevent stale mapped peer labels from restoring an +old display name. This does not change TRAINLOG_FORMAT_V1. diff --git a/docs/tests.md b/docs/tests.md index d6bacfb..1597128 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -638,3 +638,7 @@ verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_con for the established contract. No real Android install, manual TUI visual exercise, or manual MTP hardware validation was performed for Training Knowledge V1. + +Shell/naming closeout coverage includes real-PTY shell behavior, canonical +name export/import replay on temporary databases, and preservation of sets, +MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises. diff --git a/docs/tui.md b/docs/tui.md index 8f7c374..29e28c8 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -547,3 +547,7 @@ exclusive source-text cursors, bounded selection before hydration, and one read snapshot per call. Malformed cursors and matching stored timestamps fail explicitly. The full-tranche audit repair chain and independent verification passed. See [Training knowledge system V1](domain/knowledge_system.md). + +The normal Sessions hub has current/manual/completed paths only; the retained +SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher +dispatch the one registered list-search action on supported lists. diff --git a/tests/test_exercise_reconciliation.py b/tests/test_exercise_reconciliation.py index eb4f060..5b9aec9 100644 --- a/tests/test_exercise_reconciliation.py +++ b/tests/test_exercise_reconciliation.py @@ -23,6 +23,8 @@ DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d" ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936" ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a" ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d" +SEATED_CURL_ID = "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163" +SEATED_LEG_DISTINCT_ID = "ex_617007f9-7420-4408-91b9-8ffb77900f13" SCHEMA = """ PRAGMA foreign_keys=ON; @@ -485,6 +487,96 @@ def main(): assert before == after assert_no_integrity_error(connection) + # Canonical-name ownership is ID-scoped. A stale Android catalogue + # cannot rename the mapped curl back, while the distinct ex_617... + # identity and an unmapped custom exercise keep their own semantics. + names_db = root / "canonical-names.db" + create_database(names_db, fields=1) + with sqlite3.connect(names_db) as connection: + connection.execute("DELETE FROM exercises") + for exercise_id, name in ( + (SEATED_CURL_ID, "Seated leg curl"), + (SEATED_LEG_DISTINCT_ID, "Seated Leg"), + ("ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "Custom curl"), + ): + connection.execute( + "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields) " + "VALUES(?,?,lower(?),'reps','sets',0)", + (exercise_id, name, name), + ) + exercise_row = connection.execute( + "SELECT id FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,) + ).fetchone()[0] + session_row = connection.execute( + "INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", + ("se_c6daf1c6-b98b-4f18-8583-72e8c7cc3c99", "2026-09-10T08:00:00+02:00", "max_test"), + ).lastrowid + set_entry = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'sets',0,0,'none',0,'seated_leg_curl')", + ("sxe_names_set", session_row, exercise_row), + ).lastrowid + connection.execute( + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,0,11,42.5)", + (set_entry,), + ) + max_entry = connection.execute( + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,equipment_id) " + "VALUES(?,?,?,'sets',0,1,'none',0,'seated_leg_curl')", + ("sxe_names_max", session_row, exercise_row), + ).lastrowid + connection.execute( + "INSERT INTO max_results(session_exercise_row_id,max_weight_kg) VALUES(?,?)", + (max_entry, 73.0), + ) + connection.execute( + "CREATE TABLE exercise_body_zones(exercise_row_id INTEGER,zone_id TEXT,role TEXT)" + ) + connection.execute( + "INSERT INTO exercise_body_zones VALUES(?,'thighs','primary')", (exercise_row,) + ) + connection.commit() + + stale = { + "format": "trainlog-mobile-export", "version": 2, + "generated_at": "2026-09-10T09:00:00+02:00", + "exercises": [ + {"exercise_id": SEATED_CURL_ID, "name": "Seated leg curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, + {"exercise_id": SEATED_LEG_DISTINCT_ID, "name": "Seated Leg", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, + {"exercise_id": "ex_fcc75fa7-671e-4868-bab2-47033128dfb7", "name": "Custom curl", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, + ], + "sessions": [], "body_observations": [], + } + stale_path = root / "stale-names.json" + stale_path.write_text(json.dumps(stale), encoding="utf-8") + run(IMPORT_MOBILE, stale_path, "--database", names_db) + run(IMPORT_MOBILE, stale_path, "--database", names_db) + + names_catalog = root / "names-pc-catalog.json" + names_mobile = root / "names-pc-mobile.json" + run(EXPORT_CATALOG, names_catalog, "--database", names_db) + run(EXPORT_MOBILE, names_mobile, "--database", names_db, "--version", "2") + with sqlite3.connect(names_db) as connection: + assert connection.execute( + "SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,) + ).fetchone()[0] == "Flexion de genou assise" + assert connection.execute( + "SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,) + ).fetchone()[0] == "Seated Leg" + assert connection.execute( + "SELECT name FROM exercises WHERE exercise_id='ex_fcc75fa7-671e-4868-bab2-47033128dfb7'" + ).fetchone()[0] == "Custom curl" + assert connection.execute("SELECT reps,weight_kg FROM performed_sets").fetchall() == [(11, 42.5)] + assert connection.execute("SELECT max_weight_kg FROM max_results").fetchall() == [(73.0,)] + assert connection.execute("SELECT equipment_id FROM session_exercises ORDER BY position").fetchall() == [("seated_leg_curl",), ("seated_leg_curl",)] + assert connection.execute("SELECT zone_id,role FROM exercise_body_zones").fetchall() == [("thighs", "primary")] + for artifact in (names_catalog, names_mobile): + exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"] + by_id = {item["exercise_id"]: item["name"] for item in exercises} + assert by_id[SEATED_CURL_ID] == "Flexion de genou assise" + assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg" + assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl" + print("PASS exercise_reconciliation") diff --git a/tools/exercise_names.py b/tools/exercise_names.py new file mode 100644 index 0000000..05f507d --- /dev/null +++ b/tools/exercise_names.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Strict reader for the shared canonical exercise-name ownership map.""" + +import json +import unicodedata +import uuid +from pathlib import Path + + +CATALOG_PATH = ( + Path(__file__).resolve().parents[1] / "catalog" / "exercise-names-v1.json" +) + + +class ExerciseNameCatalogError(ValueError): + pass + + +def normalize_catalog_name(value): + if not isinstance(value, str): + raise ExerciseNameCatalogError("nom canonique non textuel") + normalized = " ".join(unicodedata.normalize("NFC", value.casefold()).split()) + if not normalized: + raise ExerciseNameCatalogError("nom canonique vide") + return normalized + + +def load_exercise_names(path=CATALOG_PATH): + try: + root = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ExerciseNameCatalogError( + f"catalogue de noms d'exercices illisible: {error}" + ) from error + + if not isinstance(root, dict) or set(root) != {"format", "version", "names"}: + raise ExerciseNameCatalogError("forme racine exercise-names-v1 invalide") + if root["format"] != "trainlog-exercise-names-v1" or root["version"] != 1: + raise ExerciseNameCatalogError("format/version exercise-names-v1 invalide") + if not isinstance(root["names"], list): + raise ExerciseNameCatalogError("liste names absente") + + result = {} + normalized_owners = set() + previous_names = set() + for index, item in enumerate(root["names"]): + label = f"names[{index}]" + if not isinstance(item, dict) or set(item) != { + "exercise_id", "display_name", "normalized_name", "previous_display_names" + }: + raise ExerciseNameCatalogError(f"{label}: forme invalide") + exercise_id = item["exercise_id"] + if not isinstance(exercise_id, str) or not exercise_id.startswith("ex_"): + raise ExerciseNameCatalogError(f"{label}: exercise_id invalide") + try: + parsed = uuid.UUID(exercise_id[3:]) + except (ValueError, AttributeError) as error: + raise ExerciseNameCatalogError(f"{label}: exercise_id invalide") from error + if parsed.version != 4 or str(parsed) != exercise_id[3:]: + raise ExerciseNameCatalogError(f"{label}: exercise_id UUIDv4 invalide") + display_name = item["display_name"] + normalized_name = normalize_catalog_name(display_name) + if display_name != display_name.strip() or item["normalized_name"] != normalized_name: + raise ExerciseNameCatalogError(f"{label}: nom normalisé incohérent") + if exercise_id in result or normalized_name in normalized_owners: + raise ExerciseNameCatalogError(f"{label}: identité canonique dupliquée") + previous = item["previous_display_names"] + if not isinstance(previous, list) or not previous: + raise ExerciseNameCatalogError(f"{label}: previous_display_names invalide") + local_previous = set() + for old_name in previous: + old_normalized = normalize_catalog_name(old_name) + if old_name != old_name.strip() or old_normalized == normalized_name: + raise ExerciseNameCatalogError(f"{label}: ancien nom invalide") + if old_normalized in local_previous or old_normalized in previous_names: + raise ExerciseNameCatalogError(f"{label}: ancien nom dupliqué") + local_previous.add(old_normalized) + result[exercise_id] = display_name + normalized_owners.add(normalized_name) + previous_names.update(local_previous) + if normalized_owners & previous_names: + raise ExerciseNameCatalogError("un ancien nom appartient déjà à un nom canonique") + return result diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index b949aa7..20faeba 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -6,6 +6,8 @@ import sqlite3 from datetime import datetime from pathlib import Path +from exercise_names import load_exercise_names + def default_database_path(): data_home = os.environ.get("XDG_DATA_HOME") @@ -57,6 +59,7 @@ def main(): ) try: + canonical_names = load_exercise_names() version = connection.execute( "PRAGMA user_version;" ).fetchone()[0] @@ -95,7 +98,7 @@ def main(): "exercises": [ { "exercise_id": row[0], - "name": row[1], + "name": canonical_names.get(row[0], row[1]), "recording_mode": row[2], "tracking_mode": row[3], "data_fields": row[4], diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 1581c9a..40a751f 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -9,6 +9,7 @@ from datetime import datetime from pathlib import Path from validate_json import TrainlogSemanticError, parse_timestamp +from exercise_names import load_exercise_names CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" @@ -70,13 +71,16 @@ def main(): if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10): raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)") known_equipment = supplied_equipment_ids() + canonical_names = load_exercise_names() known_equipment.update(row[0] for row in con.execute( "SELECT equipment_id FROM custom_equipment")) root = {"format": "trainlog-mobile-export", "version": args.version, "generated_at": datetime.now().astimezone().isoformat(), "exercises": [], "sessions": [], "body_observations": []} for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"): - root["exercises"].append(dict(row)) + exported = dict(row) + exported["name"] = canonical_names.get(row["exercise_id"], row["name"]) + root["exercises"].append(exported) for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"): # CONTRACT: a V3 producer must not publish history which the exact # temporal readers reject. V2 export retains its published behavior. @@ -108,7 +112,8 @@ def main(): f"session_id={session['session_id']} entry_id={entry['entry_id']}" ) item = {"entry_id": entry["entry_id"], "position": entry["position"], - "exercise_id": entry["exercise_id"], "name": entry["name"], + "exercise_id": entry["exercise_id"], + "name": canonical_names.get(entry["exercise_id"], entry["name"]), "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], "data_fields": entry["data_fields"], "load_mode": entry["load_mode"] if args.version == 3 else "none", diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 405db2e..9f050ff 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -9,6 +9,7 @@ import unicodedata from pathlib import Path from validate_json import TrainlogSemanticError, parse_timestamp +from exercise_names import ExerciseNameCatalogError, load_exercise_names FORMAT = "trainlog-mobile-export" @@ -1005,8 +1006,16 @@ def import_exercises( trace_exercises=False, ): mapping = {} + try: + canonical_names = load_exercise_names() + except ExerciseNameCatalogError as error: + raise ImportFailure(str(error)) from error - for exercise in payload["exercises"]: + for supplied_exercise in payload["exercises"]: + exercise = dict(supplied_exercise) + exercise["name"] = canonical_names.get( + exercise["exercise_id"], exercise["name"] + ) exercise_id = exercise["exercise_id"] normalized = normalize_name( exercise["name"] diff --git a/tools/validate_json.py b/tools/validate_json.py index 151b232..66c4ade 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -441,6 +441,7 @@ def validate_document( return [str(exc)] is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog" + is_exercise_names = isinstance(document, dict) and document.get("format") == "trainlog-exercise-names-v1" is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2 if is_body_zones: try: @@ -448,6 +449,13 @@ def validate_document( except TrainlogSemanticError as exc: return [str(exc)] return [] + if is_exercise_names: + try: + from exercise_names import load_exercise_names + load_exercise_names(path) + except ValueError as exc: + return [str(exc)] + return [] if not is_mobile_v2: errors = structural_errors(validator, document) if errors: @@ -486,6 +494,17 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int: else: print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}") + try: + from exercise_names import load_exercise_names + exercise_names = load_exercise_names() + print( + "PASS exercise-name catalog: " + f"{ROOT / 'catalog' / 'exercise-names-v1.json'} ({len(exercise_names)} names)" + ) + except ValueError as error: + print(f"FAIL exercise-name catalog: {error}") + failed = True + if not valid: print("FAIL test suite: no valid fixtures found") return 1 diff --git a/tui/include/trainlog/terminal.h b/tui/include/trainlog/terminal.h index 33c5b08..085ac1e 100644 --- a/tui/include/trainlog/terminal.h +++ b/tui/include/trainlog/terminal.h @@ -72,10 +72,14 @@ void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible); void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint); void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right); -bool trainlog_terminal_translate_input(uint32_t id, +/* WHY: Notcurses can report a physical key in id while eff_text holds its + * layout-resolved Unicode character. CONTRACT: Shift may produce a printable + * action character (for example French AZERTY '/'); Ctrl/Alt/Super never + * collapse into an unmodified action. */ +bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text, TrainlogInputEventType event_type, - bool shifted, - int *key); + bool shifted, bool control, bool alt, + bool super, int *key); int trainlog_terminal_get_key(TrainlogTerminal *terminal); bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5]); diff --git a/tui/src/terminal.c b/tui/src/terminal.c index a3530d6..fbdaedf 100644 --- a/tui/src/terminal.c +++ b/tui/src/terminal.c @@ -299,10 +299,10 @@ static int terminal_key(uint32_t id, bool shifted) } } -bool trainlog_terminal_translate_input(uint32_t id, +bool trainlog_terminal_translate_input(uint32_t id, uint32_t effective_text, TrainlogInputEventType event_type, - bool shifted, - int *key) + bool shifted, bool control, bool alt, + bool super, int *key) { if (key == NULL || id == 0U || id == UINT32_MAX) { return false; @@ -312,7 +312,14 @@ bool trainlog_terminal_translate_input(uint32_t id, case TRAINLOG_INPUT_UNKNOWN: case TRAINLOG_INPUT_PRESS: case TRAINLOG_INPUT_REPEAT: - *key = terminal_key(id, shifted); + /* INVARIANT: only Shift is a text-producing modifier. The effective + * character is layout-independent; do not turn Ctrl/Alt/Super chords + * into ordinary shortcuts. */ + if (effective_text >= 0x20U && effective_text <= 0x10ffffU) { + if (control || alt || super) return false; + *key = (int)effective_text; + } else + *key = terminal_key(id, shifted); return true; case TRAINLOG_INPUT_RELEASE: default: @@ -347,10 +354,12 @@ static bool terminal_read_input(TrainlogTerminal *terminal, if (id == 0U || id == UINT32_MAX) { return false; } - if (!trainlog_terminal_translate_input(id, + if (!trainlog_terminal_translate_input(id, input.eff_text[0], terminal_event_type(input.evtype), ncinput_shift_p(&input), - key)) { + ncinput_ctrl_p(&input), + ncinput_alt_p(&input), + ncinput_super_p(&input), key)) { continue; } if (utf8 != NULL) { diff --git a/tui/src/tui.c b/tui/src/tui.c index ee1c8b2..68e1b4b 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -4235,9 +4235,6 @@ static void app_shell_actions(TrainlogAppContext *app) if (route == TRAINLOG_ROUTE_HOME) app_shell_add_action(app, "session", '1', "1 Séance", 1U, TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL); - if (route == TRAINLOG_ROUTE_SESSIONS) - app_shell_add_action(app, "generate", 'g', "g Programmer", 1U, - TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_GENERATOR); if (route == TRAINLOG_ROUTE_BODY || route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS) app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U, @@ -5488,13 +5485,12 @@ static void app_shell_render_content(TrainlogAppContext *app) } else if (route == TRAINLOG_ROUTE_HOME) { trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit."); trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance"); - trainlog_surface_printf(app->content, 9, 2, "g Programmer une séance"); - trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations"); + trainlog_surface_printf(app->content, 9, 2, "5 Ajouter ou consulter des mensurations"); } else if (route == TRAINLOG_ROUTE_SESSIONS) { - static const char *const rows[] = {"Séance en cours", "Programmer une séance", - "Nouvelle séance manuelle", "Séances effectuées"}; + static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle", + "Séances effectuées"}; size_t index; - for (index = 0U; index < 4U; ++index) { + for (index = 0U; index < 3U; ++index) { trainlog_surface_set_role(app->content, app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, @@ -6214,9 +6210,8 @@ static void app_shell_primary(TrainlogAppContext *app) TrainlogAppRoute route = app->navigation.current.route; if (route == TRAINLOG_ROUTE_SESSIONS) { static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT, - TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL, - TRAINLOG_ROUTE_SESSIONS_COMPLETED}; - app_shell_open_route(app, targets[app->content_selected % 4U]); + TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED}; + app_shell_open_route(app, targets[app->content_selected % 3U]); } else if (route == TRAINLOG_ROUTE_STATS) { static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE, TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX}; @@ -7379,6 +7374,17 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) /* INVARIANT: the rendered Actions control owns its input. */ return; } + /* WHY: route controllers may legitimately own printable keys. Search is a + * shell action, so resolve its registered '/' before those controllers. + * CONTRACT: physical '/' and F7 select the same list.search action. */ + action = trainlog_actions_find_key(&app->actions, key); + if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH && + app_shell_is_list_route(app->navigation.current.route)) { + app->search.open = true; + app->search.focused = true; + app->focus = TRAINLOG_FOCUS_SEARCH; + 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; @@ -7388,7 +7394,7 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS || app->navigation.current.route == TRAINLOG_ROUTE_STATS) && (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { - size_t count = app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ? 4U : 3U; + size_t count = 3U; if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected; if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected; return; @@ -7641,20 +7647,7 @@ shell_nonanalytics_input: else if (key == TRAINLOG_KEY_F3) key = '3'; else if (key == TRAINLOG_KEY_F4) key = '4'; else if (key == TRAINLOG_KEY_F5) key = '5'; - if ((key == 'g' || key == 'G') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) { - app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_GENERATOR); return; - } action = trainlog_actions_find_key(&app->actions, key); - if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH && - app_shell_is_list_route(app->navigation.current.route)) { - /* CONTRACT: the physical '/' follows input adapter -> top-level action - * registry -> focus/search -> list filter. F7 invokes the same stable - * action and Escape retains the established clear-then-close behavior. */ - app->search.open = true; - app->search.focused = true; - app->focus = TRAINLOG_FOCUS_SEARCH; - return; - } if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) { app_shell_open_route(app, action->route); return; } diff --git a/tui/tests/test_terminal_input.c b/tui/tests/test_terminal_input.c index 6bf4d0d..4b480fc 100644 --- a/tui/tests/test_terminal_input.c +++ b/tui/tests/test_terminal_input.c @@ -29,24 +29,24 @@ static bool test_event_type_policy(void) int key = 12345; CHECK(trainlog_terminal_translate_input( - NCKEY_RIGHT, TRAINLOG_INPUT_UNKNOWN, false, &key)); + NCKEY_RIGHT, 0U, TRAINLOG_INPUT_UNKNOWN, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_RIGHT); CHECK(trainlog_terminal_translate_input( - NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, &key)); + NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_RIGHT); CHECK(trainlog_terminal_translate_input( - NCKEY_RIGHT, TRAINLOG_INPUT_REPEAT, false, &key)); + NCKEY_RIGHT, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_RIGHT); key = 12345; CHECK(!trainlog_terminal_translate_input( - NCKEY_RIGHT, TRAINLOG_INPUT_RELEASE, false, &key)); + NCKEY_RIGHT, 0U, TRAINLOG_INPUT_RELEASE, false, false, false, false, &key)); CHECK(key == 12345); CHECK(!trainlog_terminal_translate_input( - NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key)); + NCKEY_RIGHT, 0U, (TrainlogInputEventType)99, false, false, false, false, &key)); CHECK(key == 12345); return true; @@ -57,35 +57,48 @@ static bool test_key_translation(void) int key = TRAINLOG_KEY_NONE; CHECK(trainlog_terminal_translate_input( - NCKEY_TAB, TRAINLOG_INPUT_PRESS, false, &key)); + NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_TAB); CHECK(trainlog_terminal_translate_input( - NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key)); + NCKEY_TAB, 0U, TRAINLOG_INPUT_PRESS, true, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_SHIFT_TAB); CHECK(trainlog_terminal_translate_input( - NCKEY_F06, TRAINLOG_INPUT_PRESS, false, &key)); + NCKEY_F06, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_F6); CHECK(trainlog_terminal_translate_input( - NCKEY_F07, TRAINLOG_INPUT_REPEAT, false, &key)); + NCKEY_F07, 0U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_F7); CHECK(trainlog_terminal_translate_input( - 27U, TRAINLOG_INPUT_PRESS, false, &key)); + 27U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(key == TRAINLOG_KEY_ESCAPE); CHECK(trainlog_terminal_translate_input( - 0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key)); + 0x00e9U, 0x00e9U, TRAINLOG_INPUT_REPEAT, false, false, false, false, &key)); CHECK(key == 0x00e9); CHECK(!trainlog_terminal_translate_input( - 0U, TRAINLOG_INPUT_PRESS, false, &key)); + 0U, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(!trainlog_terminal_translate_input( - UINT32_MAX, TRAINLOG_INPUT_PRESS, false, &key)); + UINT32_MAX, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); CHECK(!trainlog_terminal_translate_input( - NCKEY_RIGHT, TRAINLOG_INPUT_PRESS, false, NULL)); + NCKEY_RIGHT, 0U, TRAINLOG_INPUT_PRESS, false, false, false, false, NULL)); + + /* French AZERTY commonly needs Shift to yield '/'. eff_text, rather than + * the physical id, is the action character and must reach list.search. */ + CHECK(trainlog_terminal_translate_input( + ':', '/', TRAINLOG_INPUT_PRESS, true, false, false, false, &key)); + CHECK(key == '/'); + CHECK(trainlog_terminal_translate_input( + '/', '/', TRAINLOG_INPUT_PRESS, false, false, false, false, &key)); + CHECK(key == '/'); + CHECK(!trainlog_terminal_translate_input( + ':', '/', TRAINLOG_INPUT_PRESS, true, true, false, false, &key)); + CHECK(!trainlog_terminal_translate_input( + ':', '/', TRAINLOG_INPUT_PRESS, true, false, true, false, &key)); return true; }