feat(exercises): add canonical naming and finalize app shell
This commit is contained in:
parent
1f027743f2
commit
b2249b10b1
31 changed files with 543 additions and 65 deletions
|
|
@ -338,3 +338,7 @@ Added:
|
||||||
|
|
||||||
Estimated analytics remain derived display values and are never persisted as
|
Estimated analytics remain derived display values and are never persisted as
|
||||||
direct measurements. Android remains capture-only for this feature.
|
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).
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,7 @@ BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
DESKTOP_TESTS=39/39 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.
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
onBody = {},
|
onBody = {},
|
||||||
onOpenLatestSession = {},
|
onOpenLatestSession = {},
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onGenerateSession = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String, String> {
|
||||||
|
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<String, String>()
|
||||||
|
val canonicalOwners = mutableSetOf<String>()
|
||||||
|
val previousOwners = mutableSetOf<String>()
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -274,6 +274,7 @@ class TrainlogRepository(
|
||||||
ANDROID_DATABASE_NAME,
|
ANDROID_DATABASE_NAME,
|
||||||
) {
|
) {
|
||||||
private val applicationContext = context.applicationContext
|
private val applicationContext = context.applicationContext
|
||||||
|
private val canonicalExerciseNames = ExerciseNameCatalog.load(applicationContext)
|
||||||
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
||||||
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
||||||
private val sessionGenerationPolicy =
|
private val sessionGenerationPolicy =
|
||||||
|
|
@ -1534,10 +1535,8 @@ class TrainlogRepository(
|
||||||
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
|
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
|
||||||
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
|
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
|
||||||
|
|
||||||
val name =
|
val suppliedName = item.getString("name")
|
||||||
item.getString(
|
val name = canonicalExerciseNames[exerciseId] ?: suppliedName
|
||||||
"name"
|
|
||||||
)
|
|
||||||
|
|
||||||
val normalized =
|
val normalized =
|
||||||
normalizeName(
|
normalizeName(
|
||||||
|
|
@ -1600,6 +1599,12 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!NewExerciseProfile(
|
||||||
|
name = suppliedName,
|
||||||
|
recordingMode = recording,
|
||||||
|
trackingMode = tracking,
|
||||||
|
dataFields = dataFields,
|
||||||
|
).validate() ||
|
||||||
!NewExerciseProfile(
|
!NewExerciseProfile(
|
||||||
name = name,
|
name = name,
|
||||||
recordingMode = recording,
|
recordingMode = recording,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ fun HomeScreen(
|
||||||
latestSession: SessionSummary?,
|
latestSession: SessionSummary?,
|
||||||
latestMaximum: LatestExerciseMax?,
|
latestMaximum: LatestExerciseMax?,
|
||||||
onSession: () -> Unit,
|
onSession: () -> Unit,
|
||||||
onGenerateSession: () -> Unit,
|
|
||||||
onBody: () -> Unit,
|
onBody: () -> Unit,
|
||||||
onOpenLatestSession: (String) -> Unit,
|
onOpenLatestSession: (String) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
|
|
@ -26,10 +25,8 @@ fun HomeScreen(
|
||||||
if (activeDraft != null) {
|
if (activeDraft != null) {
|
||||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
|
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)
|
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 {
|
} else {
|
||||||
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
|
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||||
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
latestSession?.let { session ->
|
latestSession?.let { session ->
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
fun SessionsHub(
|
fun SessionsHub(
|
||||||
draft: ActiveSessionDraft?,
|
draft: ActiveSessionDraft?,
|
||||||
onResume: () -> Unit,
|
onResume: () -> Unit,
|
||||||
onGenerate: () -> Unit,
|
|
||||||
onManual: () -> Unit,
|
onManual: () -> Unit,
|
||||||
onHistory: () -> 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)
|
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
|
||||||
}
|
}
|
||||||
TrainlogFrame("Préparer") {
|
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("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)
|
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
||||||
|
|
|
||||||
|
|
@ -76,10 +76,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
AppRoute.Home -> HomeScreen(
|
AppRoute.Home -> HomeScreen(
|
||||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
||||||
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||||
::openManualSession, { open(AppRoute.SessionGenerator) },
|
::openManualSession,
|
||||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
{ 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.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) }, {
|
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."
|
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,49 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(1, repo.listExercises().count { it.exerciseId == canonicalId })
|
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
|
@Test
|
||||||
fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() {
|
fun marcheReconciliationKeepsRicherProfileAndTwoOccurrenceValues() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
|
||||||
22
catalog/exercise-names-v1.json
Normal file
22
catalog/exercise-names-v1.json
Normal file
|
|
@ -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"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -637,3 +637,7 @@ The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonic
|
||||||
and equipment identity syntax, bidirectional exercise/capability compatibility,
|
and equipment identity syntax, bidirectional exercise/capability compatibility,
|
||||||
HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its
|
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).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
No estimated body-fat, fat-mass, lean-mass, ratio, or asymmetry value is stored
|
||||||
as if it were a real measurement.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
temporal documentation, Android loader, Meson input, and C role-only query
|
||||||
findings were repaired, independently verified, and final-validated;
|
findings were repaired, independently verified, and final-validated;
|
||||||
`TRAINING_KNOWLEDGE_V1=PASS`.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -352,3 +352,7 @@ the same comparison; malformed cursors or matching stored timestamps report
|
||||||
explicit errors. The initial audit's stale temporal documentation, Android
|
explicit errors. The initial audit's stale temporal documentation, Android
|
||||||
loader, Meson input, and C role-only query findings were repaired and
|
loader, Meson input, and C role-only query findings were repaired and
|
||||||
independently verified; final validation passed.
|
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.
|
||||||
|
|
|
||||||
37
docs/reviews/exercise_naming_v1_audit.md
Normal file
37
docs/reviews/exercise_naming_v1_audit.md
Normal file
|
|
@ -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.
|
||||||
30
docs/reviews/stats_v1_design.md
Normal file
30
docs/reviews/stats_v1_design.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -436,3 +436,14 @@ assistance interpreted as external load
|
||||||
body-composition estimates presented as direct measurements
|
body-composition estimates presented as direct measurements
|
||||||
incompatible changes to Trainlog JSON v1 without a new format version
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
no occurrence-level equipment signal, so a neighboring V2 companion belongs to
|
||||||
a different generation and is ignored. This preserves rather than clears
|
a different generation and is ignored. This preserves rather than clears
|
||||||
existing equipment.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -638,3 +638,7 @@ verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_con
|
||||||
for the established contract. No real Android install,
|
for the established contract. No real Android install,
|
||||||
manual TUI visual exercise, or manual MTP hardware validation was performed
|
manual TUI visual exercise, or manual MTP hardware validation was performed
|
||||||
for Training Knowledge V1.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
read snapshot per call. Malformed cursors and matching stored timestamps fail
|
||||||
explicitly. The full-tranche audit repair chain and independent verification
|
explicitly. The full-tranche audit repair chain and independent verification
|
||||||
passed. See [Training knowledge system V1](domain/knowledge_system.md).
|
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.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ DESKTOP_WALK_ID = "ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d"
|
||||||
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
|
ANDROID_WALK_ID = "ex_23212d79-52ce-4195-914d-dd983f133936"
|
||||||
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
|
ANDROID_SESSION_ID = "se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a"
|
||||||
ANDROID_WALK_ENTRY_ID = "sxe_f25142c8-455e-4346-9bfc-31d0989e275d"
|
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 = """
|
SCHEMA = """
|
||||||
PRAGMA foreign_keys=ON;
|
PRAGMA foreign_keys=ON;
|
||||||
|
|
@ -485,6 +487,96 @@ def main():
|
||||||
assert before == after
|
assert before == after
|
||||||
assert_no_integrity_error(connection)
|
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")
|
print("PASS exercise_reconciliation")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
83
tools/exercise_names.py
Normal file
83
tools/exercise_names.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,6 +6,8 @@ import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from exercise_names import load_exercise_names
|
||||||
|
|
||||||
|
|
||||||
def default_database_path():
|
def default_database_path():
|
||||||
data_home = os.environ.get("XDG_DATA_HOME")
|
data_home = os.environ.get("XDG_DATA_HOME")
|
||||||
|
|
@ -57,6 +59,7 @@ def main():
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
canonical_names = load_exercise_names()
|
||||||
version = connection.execute(
|
version = connection.execute(
|
||||||
"PRAGMA user_version;"
|
"PRAGMA user_version;"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
@ -95,7 +98,7 @@ def main():
|
||||||
"exercises": [
|
"exercises": [
|
||||||
{
|
{
|
||||||
"exercise_id": row[0],
|
"exercise_id": row[0],
|
||||||
"name": row[1],
|
"name": canonical_names.get(row[0], row[1]),
|
||||||
"recording_mode": row[2],
|
"recording_mode": row[2],
|
||||||
"tracking_mode": row[3],
|
"tracking_mode": row[3],
|
||||||
"data_fields": row[4],
|
"data_fields": row[4],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
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"
|
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):
|
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)")
|
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
|
canonical_names = load_exercise_names()
|
||||||
known_equipment.update(row[0] for row in con.execute(
|
known_equipment.update(row[0] for row in con.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
root = {"format": "trainlog-mobile-export", "version": args.version,
|
root = {"format": "trainlog-mobile-export", "version": args.version,
|
||||||
"generated_at": datetime.now().astimezone().isoformat(),
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
"exercises": [], "sessions": [], "body_observations": []}
|
"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"):
|
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"):
|
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
|
# CONTRACT: a V3 producer must not publish history which the exact
|
||||||
# temporal readers reject. V2 export retains its published behavior.
|
# 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']}"
|
f"session_id={session['session_id']} entry_id={entry['entry_id']}"
|
||||||
)
|
)
|
||||||
item = {"entry_id": entry["entry_id"], "position": entry["position"],
|
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"],
|
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
||||||
"data_fields": entry["data_fields"],
|
"data_fields": entry["data_fields"],
|
||||||
"load_mode": entry["load_mode"] if args.version == 3 else "none",
|
"load_mode": entry["load_mode"] if args.version == 3 else "none",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from validate_json import TrainlogSemanticError, parse_timestamp
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||||
|
from exercise_names import ExerciseNameCatalogError, load_exercise_names
|
||||||
|
|
||||||
|
|
||||||
FORMAT = "trainlog-mobile-export"
|
FORMAT = "trainlog-mobile-export"
|
||||||
|
|
@ -1005,8 +1006,16 @@ def import_exercises(
|
||||||
trace_exercises=False,
|
trace_exercises=False,
|
||||||
):
|
):
|
||||||
mapping = {}
|
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"]
|
exercise_id = exercise["exercise_id"]
|
||||||
normalized = normalize_name(
|
normalized = normalize_name(
|
||||||
exercise["name"]
|
exercise["name"]
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,7 @@ def validate_document(
|
||||||
return [str(exc)]
|
return [str(exc)]
|
||||||
|
|
||||||
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
|
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
|
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
|
||||||
if is_body_zones:
|
if is_body_zones:
|
||||||
try:
|
try:
|
||||||
|
|
@ -448,6 +449,13 @@ def validate_document(
|
||||||
except TrainlogSemanticError as exc:
|
except TrainlogSemanticError as exc:
|
||||||
return [str(exc)]
|
return [str(exc)]
|
||||||
return []
|
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:
|
if not is_mobile_v2:
|
||||||
errors = structural_errors(validator, document)
|
errors = structural_errors(validator, document)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -486,6 +494,17 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
|
||||||
else:
|
else:
|
||||||
print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}")
|
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:
|
if not valid:
|
||||||
print("FAIL test suite: no valid fixtures found")
|
print("FAIL test suite: no valid fixtures found")
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -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_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint);
|
||||||
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left,
|
void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left,
|
||||||
int bottom, int right);
|
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,
|
TrainlogInputEventType event_type,
|
||||||
bool shifted,
|
bool shifted, bool control, bool alt,
|
||||||
int *key);
|
bool super, int *key);
|
||||||
int trainlog_terminal_get_key(TrainlogTerminal *terminal);
|
int trainlog_terminal_get_key(TrainlogTerminal *terminal);
|
||||||
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
|
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
|
||||||
char utf8[5]);
|
char utf8[5]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
TrainlogInputEventType event_type,
|
||||||
bool shifted,
|
bool shifted, bool control, bool alt,
|
||||||
int *key)
|
bool super, int *key)
|
||||||
{
|
{
|
||||||
if (key == NULL || id == 0U || id == UINT32_MAX) {
|
if (key == NULL || id == 0U || id == UINT32_MAX) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -312,7 +312,14 @@ bool trainlog_terminal_translate_input(uint32_t id,
|
||||||
case TRAINLOG_INPUT_UNKNOWN:
|
case TRAINLOG_INPUT_UNKNOWN:
|
||||||
case TRAINLOG_INPUT_PRESS:
|
case TRAINLOG_INPUT_PRESS:
|
||||||
case TRAINLOG_INPUT_REPEAT:
|
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;
|
return true;
|
||||||
case TRAINLOG_INPUT_RELEASE:
|
case TRAINLOG_INPUT_RELEASE:
|
||||||
default:
|
default:
|
||||||
|
|
@ -347,10 +354,12 @@ static bool terminal_read_input(TrainlogTerminal *terminal,
|
||||||
if (id == 0U || id == UINT32_MAX) {
|
if (id == 0U || id == UINT32_MAX) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!trainlog_terminal_translate_input(id,
|
if (!trainlog_terminal_translate_input(id, input.eff_text[0],
|
||||||
terminal_event_type(input.evtype),
|
terminal_event_type(input.evtype),
|
||||||
ncinput_shift_p(&input),
|
ncinput_shift_p(&input),
|
||||||
key)) {
|
ncinput_ctrl_p(&input),
|
||||||
|
ncinput_alt_p(&input),
|
||||||
|
ncinput_super_p(&input), key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (utf8 != NULL) {
|
if (utf8 != NULL) {
|
||||||
|
|
|
||||||
|
|
@ -4235,9 +4235,6 @@ static void app_shell_actions(TrainlogAppContext *app)
|
||||||
if (route == TRAINLOG_ROUTE_HOME)
|
if (route == TRAINLOG_ROUTE_HOME)
|
||||||
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
|
app_shell_add_action(app, "session", '1', "1 Séance", 1U,
|
||||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL);
|
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 ||
|
if (route == TRAINLOG_ROUTE_BODY ||
|
||||||
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
|
route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS)
|
||||||
app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U,
|
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) {
|
} 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, 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, 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, 9, 2, "5 Ajouter ou consulter des mensurations");
|
||||||
trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations");
|
|
||||||
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
|
} else if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||||||
static const char *const rows[] = {"Séance en cours", "Programmer une séance",
|
static const char *const rows[] = {"Séance en cours", "Nouvelle séance manuelle",
|
||||||
"Nouvelle séance manuelle", "Séances effectuées"};
|
"Séances effectuées"};
|
||||||
size_t index;
|
size_t index;
|
||||||
for (index = 0U; index < 4U; ++index) {
|
for (index = 0U; index < 3U; ++index) {
|
||||||
trainlog_surface_set_role(app->content,
|
trainlog_surface_set_role(app->content,
|
||||||
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT,
|
||||||
TRAINLOG_RGB_BASE,
|
TRAINLOG_RGB_BASE,
|
||||||
|
|
@ -6214,9 +6210,8 @@ static void app_shell_primary(TrainlogAppContext *app)
|
||||||
TrainlogAppRoute route = app->navigation.current.route;
|
TrainlogAppRoute route = app->navigation.current.route;
|
||||||
if (route == TRAINLOG_ROUTE_SESSIONS) {
|
if (route == TRAINLOG_ROUTE_SESSIONS) {
|
||||||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
|
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT,
|
||||||
TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL,
|
TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED};
|
||||||
TRAINLOG_ROUTE_SESSIONS_COMPLETED};
|
app_shell_open_route(app, targets[app->content_selected % 3U]);
|
||||||
app_shell_open_route(app, targets[app->content_selected % 4U]);
|
|
||||||
} else if (route == TRAINLOG_ROUTE_STATS) {
|
} else if (route == TRAINLOG_ROUTE_STATS) {
|
||||||
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
|
static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE,
|
||||||
TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX};
|
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. */
|
/* INVARIANT: the rendered Actions control owns its input. */
|
||||||
return;
|
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_exercise_controller(app, key)) return;
|
||||||
if (app_shell_dispatch_equipment_controller(app, key)) return;
|
if (app_shell_dispatch_equipment_controller(app, key)) return;
|
||||||
if (app_shell_dispatch_body_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 ||
|
if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ||
|
||||||
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
|
app->navigation.current.route == TRAINLOG_ROUTE_STATS) &&
|
||||||
(key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) {
|
(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_UP && app->content_selected > 0U) --app->content_selected;
|
||||||
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
|
if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected;
|
||||||
return;
|
return;
|
||||||
|
|
@ -7641,20 +7647,7 @@ shell_nonanalytics_input:
|
||||||
else if (key == TRAINLOG_KEY_F3) key = '3';
|
else if (key == TRAINLOG_KEY_F3) key = '3';
|
||||||
else if (key == TRAINLOG_KEY_F4) key = '4';
|
else if (key == TRAINLOG_KEY_F4) key = '4';
|
||||||
else if (key == TRAINLOG_KEY_F5) key = '5';
|
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);
|
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) {
|
if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) {
|
||||||
app_shell_open_route(app, action->route); return;
|
app_shell_open_route(app, action->route); return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,24 @@ static bool test_event_type_policy(void)
|
||||||
int key = 12345;
|
int key = 12345;
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_RIGHT);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_RIGHT);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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);
|
CHECK(key == TRAINLOG_KEY_RIGHT);
|
||||||
|
|
||||||
key = 12345;
|
key = 12345;
|
||||||
CHECK(!trainlog_terminal_translate_input(
|
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(key == 12345);
|
||||||
|
|
||||||
CHECK(!trainlog_terminal_translate_input(
|
CHECK(!trainlog_terminal_translate_input(
|
||||||
NCKEY_RIGHT, (TrainlogInputEventType)99, false, &key));
|
NCKEY_RIGHT, 0U, (TrainlogInputEventType)99, false, false, false, false, &key));
|
||||||
CHECK(key == 12345);
|
CHECK(key == 12345);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -57,35 +57,48 @@ static bool test_key_translation(void)
|
||||||
int key = TRAINLOG_KEY_NONE;
|
int key = TRAINLOG_KEY_NONE;
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_TAB);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_SHIFT_TAB);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_F6);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_F7);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == TRAINLOG_KEY_ESCAPE);
|
||||||
|
|
||||||
CHECK(trainlog_terminal_translate_input(
|
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(key == 0x00e9);
|
||||||
|
|
||||||
CHECK(!trainlog_terminal_translate_input(
|
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(
|
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(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue