feat(trainlog): adopt machine-specific exercise model
This commit is contained in:
parent
42b12b8e1e
commit
bb3790588f
66 changed files with 1581 additions and 214 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,5 +1,16 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased — MACHINE_EXERCISE_MODEL_V1
|
||||||
|
|
||||||
|
- Add desktop and Android schema v13 for machine-specific exercise metadata.
|
||||||
|
- Preserve stable IDs for pure renames and introduce the five approved UUIDv4
|
||||||
|
identities for Plate Loaded Leg Press, Treadmill, Pec Fly, Chin Assist, and
|
||||||
|
Dip Assist.
|
||||||
|
- Make exact manifest splits transactional and idempotent while preserving all
|
||||||
|
occurrence child facts and unresolved provenance.
|
||||||
|
- Remove Equipment from normal Android and TUI navigation while retaining its
|
||||||
|
Phase 1 storage and sync compatibility roles.
|
||||||
|
|
||||||
- Added `PERCENT_MAX_INPUT_V1` as a transient user calculator in TUI planning
|
- Added `PERCENT_MAX_INPUT_V1` as a transient user calculator in TUI planning
|
||||||
and both generator previews: exact exercise/equipment external-load context,
|
and both generator previews: exact exercise/equipment external-load context,
|
||||||
integer 1..100, `MAX × percentage / 100`, no recommendation, and only the
|
integer 1..100, `MAX × percentage / 100`, no recommendation, and only the
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
|
||||||
```
|
```
|
||||||
|
|
||||||
`APP_SHELL_V1` provides the shared seven-section application shell on both
|
`APP_SHELL_V1` provides the shared seven-section application shell on both
|
||||||
platforms: **Accueil**, **Séances**, **Exercices**, **Équipements**,
|
platforms: **Accueil**, **Séances**, **Exercices**,
|
||||||
**Statistiques**, **Synchronisation**, and **Paramètres**. Completed history
|
**Statistiques**, **Synchronisation**, and **Paramètres**. Completed history
|
||||||
and the existing generator now live under **Séances**; existing body and MAX
|
and the existing generator now live under **Séances**; existing body and MAX
|
||||||
views live under **Statistiques**. It records no new statistics, schema, or
|
views live under **Statistiques**. It records no new statistics, schema, or
|
||||||
|
|
@ -205,7 +205,7 @@ export or desktop synchronization as completed sessions.
|
||||||
Schema migrations are additive and preserve existing capture data. See
|
Schema migrations are additive and preserve existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
||||||
|
|
||||||
The current Android schema is v12. Its additive v4 -> v12 chain adds the shared
|
The current Android schema is v13. Its additive v4 -> v13 chain adds the shared
|
||||||
equipment catalogue, per-occurrence equipment links, durable occurrence
|
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||||
identities, custom-equipment definition support, explicit MAX results and
|
identities, custom-equipment definition support, explicit MAX results and
|
||||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
stable-source Test max resumption, then direct primary/secondary body-zone
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,12 @@ data class EquipmentKnowledge(
|
||||||
val scientificStatusScope: String,
|
val scientificStatusScope: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class EquipmentExerciseRelation(
|
||||||
|
val equipmentId: String, val exerciseId: String, val relationType: String,
|
||||||
|
val configurationLabel: String?, val confidence: KnowledgeConfidence,
|
||||||
|
val sourceRefs: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
data class KnowledgeExerciseFilters(
|
data class KnowledgeExerciseFilters(
|
||||||
val movementPatternId: String? = null,
|
val movementPatternId: String? = null,
|
||||||
val muscleId: String? = null,
|
val muscleId: String? = null,
|
||||||
|
|
@ -107,7 +113,7 @@ data class KnowledgeExerciseFilters(
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immutable TRAINING KNOWLEDGE V1 view over the six shared repository assets.
|
* Immutable training-knowledge view over the shared repository assets.
|
||||||
* WHY: strict loading makes malformed scientific data a deployment failure and
|
* WHY: strict loading makes malformed scientific data a deployment failure and
|
||||||
* prevents Android from quietly developing a second, hand-maintained taxonomy.
|
* prevents Android from quietly developing a second, hand-maintained taxonomy.
|
||||||
*/
|
*/
|
||||||
|
|
@ -115,6 +121,7 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
val references: List<ScienceReference>, val muscles: List<MuscleKnowledge>,
|
val references: List<ScienceReference>, val muscles: List<MuscleKnowledge>,
|
||||||
val jointActions: List<JointActionKnowledge>, val movementPatterns: List<MovementPatternKnowledge>,
|
val jointActions: List<JointActionKnowledge>, val movementPatterns: List<MovementPatternKnowledge>,
|
||||||
val exercises: List<ExerciseKnowledge>, val equipment: List<EquipmentKnowledge>,
|
val exercises: List<ExerciseKnowledge>, val equipment: List<EquipmentKnowledge>,
|
||||||
|
val equipmentExerciseRelations: List<EquipmentExerciseRelation>,
|
||||||
private val bodyZones: BodyZoneCatalog,
|
private val bodyZones: BodyZoneCatalog,
|
||||||
) {
|
) {
|
||||||
private val referencesById = references.associateBy { it.refId }
|
private val referencesById = references.associateBy { it.refId }
|
||||||
|
|
@ -123,6 +130,8 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
private val patternsById = movementPatterns.associateBy { it.patternId }
|
private val patternsById = movementPatterns.associateBy { it.patternId }
|
||||||
private val exercisesById = exercises.associateBy { it.exerciseId }
|
private val exercisesById = exercises.associateBy { it.exerciseId }
|
||||||
private val equipmentById = equipment.associateBy { it.equipmentId }
|
private val equipmentById = equipment.associateBy { it.equipmentId }
|
||||||
|
private val relationsByEquipment = equipmentExerciseRelations.groupBy { it.equipmentId }
|
||||||
|
private val relationsByExercise = equipmentExerciseRelations.groupBy { it.exerciseId }
|
||||||
|
|
||||||
fun getReference(refId: String) = referencesById[refId]
|
fun getReference(refId: String) = referencesById[refId]
|
||||||
fun getMuscle(muscleId: String) = musclesById[muscleId]
|
fun getMuscle(muscleId: String) = musclesById[muscleId]
|
||||||
|
|
@ -133,6 +142,28 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
exercisesById[exerciseId]?.takeIf { it.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL }
|
exercisesById[exerciseId]?.takeIf { it.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL }
|
||||||
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = equipmentById[equipmentId]
|
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = equipmentById[equipmentId]
|
||||||
|
|
||||||
|
fun listExercisesForEquipment(equipmentId: String): List<ExerciseKnowledge> {
|
||||||
|
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
|
||||||
|
return relationsByEquipment[equipmentId].orEmpty().map { exercisesById.getValue(it.exerciseId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listRelationsForEquipment(equipmentId: String): List<EquipmentExerciseRelation> =
|
||||||
|
relationsByEquipment[equipmentId].orEmpty()
|
||||||
|
|
||||||
|
fun listEquipmentForExercise(exerciseId: String): List<EquipmentKnowledge> {
|
||||||
|
require(exercisesById.containsKey(exerciseId)) { "exercise_id inconnu: $exerciseId" }
|
||||||
|
return relationsByExercise[exerciseId].orEmpty().map { equipmentById.getValue(it.equipmentId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun listEquipmentForBodyZone(zoneId: String): List<EquipmentKnowledge> {
|
||||||
|
val exerciseIds = queryExercises(KnowledgeExerciseFilters(scientificZoneId = zoneId))
|
||||||
|
.map { it.exerciseId }.toSet()
|
||||||
|
// CONTRACT: BODY ZONE membership comes from resolved exercise anatomy,
|
||||||
|
// never from an equipment name, capability label, or custom record.
|
||||||
|
return equipment.filter { item -> relationsByEquipment[item.equipmentId].orEmpty()
|
||||||
|
.any { it.exerciseId in exerciseIds } }
|
||||||
|
}
|
||||||
|
|
||||||
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? =
|
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? =
|
||||||
resolvedInterpretation(exerciseId)?.let { ScientificBodyZoneMapping(it.primaryZoneId, it.secondaryZoneIds) }
|
resolvedInterpretation(exerciseId)?.let { ScientificBodyZoneMapping(it.primaryZoneId, it.secondaryZoneIds) }
|
||||||
|
|
||||||
|
|
@ -148,7 +179,7 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
|
|
||||||
fun listCompatibleExercises(equipmentId: String): List<ExerciseKnowledge> {
|
fun listCompatibleExercises(equipmentId: String): List<ExerciseKnowledge> {
|
||||||
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
|
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
|
||||||
return queryExercises(KnowledgeExerciseFilters(equipmentId = equipmentId))
|
return listExercisesForEquipment(equipmentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun queryExercises(filters: KnowledgeExerciseFilters): List<ExerciseKnowledge> {
|
fun queryExercises(filters: KnowledgeExerciseFilters): List<ExerciseKnowledge> {
|
||||||
|
|
@ -166,7 +197,8 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
val interpretation = resolvedInterpretation(exercise.exerciseId) ?: return@filter false
|
val interpretation = resolvedInterpretation(exercise.exerciseId) ?: return@filter false
|
||||||
(filters.movementPatternId == null || filters.movementPatternId in interpretation.patternIds) &&
|
(filters.movementPatternId == null || filters.movementPatternId in interpretation.patternIds) &&
|
||||||
(filters.muscleId == null || filters.muscleId in interpretation.muscles(filters.muscleRole!!)) &&
|
(filters.muscleId == null || filters.muscleId in interpretation.muscles(filters.muscleRole!!)) &&
|
||||||
(filters.equipmentId == null || filters.equipmentId in exercise.equipmentIds) &&
|
(filters.equipmentId == null || relationsByEquipment[filters.equipmentId].orEmpty()
|
||||||
|
.any { it.exerciseId == exercise.exerciseId }) &&
|
||||||
(zones == null || interpretation.primaryZoneId in zones ||
|
(zones == null || interpretation.primaryZoneId in zones ||
|
||||||
interpretation.secondaryZoneIds.any { it in zones })
|
interpretation.secondaryZoneIds.any { it in zones })
|
||||||
}
|
}
|
||||||
|
|
@ -209,6 +241,13 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
val patternJson = root("movement-patterns-v1.json", "trainlog-movement-patterns-v1", "movement_patterns")
|
val patternJson = root("movement-patterns-v1.json", "trainlog-movement-patterns-v1", "movement_patterns")
|
||||||
val exerciseJson = root("exercise-knowledge-v1.json", "trainlog-exercise-knowledge-v1", "exercises")
|
val exerciseJson = root("exercise-knowledge-v1.json", "trainlog-exercise-knowledge-v1", "exercises")
|
||||||
val equipmentJson = root("equipment-knowledge-v1.json", "trainlog-equipment-knowledge-v1", "equipment")
|
val equipmentJson = root("equipment-knowledge-v1.json", "trainlog-equipment-knowledge-v1", "equipment")
|
||||||
|
val relationText = assetText("equipment-exercise-relations-v2.json")
|
||||||
|
DuplicateJsonKeyValidator.validate(relationText)
|
||||||
|
val relationRoot = JSONObject(relationText)
|
||||||
|
requireKeys(relationRoot, setOf("format", "version", "equipment_relations"), "equipment_relations")
|
||||||
|
check(relationRoot.getString("format") == "trainlog-equipment-exercise-relations-v2" &&
|
||||||
|
relationRoot.rawInt("version") == 2) { "equipment_relations: version non prise en charge" }
|
||||||
|
val relationJson = relationRoot.getJSONArray("equipment_relations")
|
||||||
|
|
||||||
val references = referenceJson.objects("ref_id") { item ->
|
val references = referenceJson.objects("ref_id") { item ->
|
||||||
requireKeys(item, setOf("ref_id", "title", "authors_or_organization", "year", "type", "url", "topics", "notes", "limitations", "doi", "pmid", "accessed_on"), "reference", setOf("publication_note"))
|
requireKeys(item, setOf("ref_id", "title", "authors_or_organization", "year", "type", "url", "topics", "notes", "limitations", "doi", "pmid", "accessed_on"), "reference", setOf("publication_note"))
|
||||||
|
|
@ -319,7 +358,34 @@ class TrainingKnowledgeCatalog private constructor(
|
||||||
EquipmentKnowledge(item.text("equipment_id"), item.nullableString("manufacturer"), item.nullableString("model"), item.text("identification_status"), enumValue(item.text("scientific_status"), EquipmentScienceStatus.entries), item.text("catalog_type"), item.nullableString("catalog_load_semantics")?.let { enumValue(it, EquipmentLoadSemantics.entries) }, item.text("mechanics"), item.text("evidence_type"), conf, refs, capabilities, item.rawBoolean("requires_actual_exercise"), item.strings("limitations"), item.optionalString("audit_note"), item.text("scientific_status_scope"))
|
EquipmentKnowledge(item.text("equipment_id"), item.nullableString("manufacturer"), item.nullableString("model"), item.text("identification_status"), enumValue(item.text("scientific_status"), EquipmentScienceStatus.entries), item.text("catalog_type"), item.nullableString("catalog_load_semantics")?.let { enumValue(it, EquipmentLoadSemantics.entries) }, item.text("mechanics"), item.text("evidence_type"), conf, refs, capabilities, item.rawBoolean("requires_actual_exercise"), item.strings("limitations"), item.optionalString("audit_note"), item.text("scientific_status_scope"))
|
||||||
}
|
}
|
||||||
exercises.forEach { exercise -> exercise.equipmentIds.forEach { check(exercise.exerciseId to it in linked) { "compatibilité exercice/équipement asymétrique" } } }
|
exercises.forEach { exercise -> exercise.equipmentIds.forEach { check(exercise.exerciseId to it in linked) { "compatibilité exercice/équipement asymétrique" } } }
|
||||||
return TrainingKnowledgeCatalog(references, muscles, actions, patterns, exercises, equipment, bodyZones)
|
val relationPairs = mutableSetOf<Pair<String, String>>()
|
||||||
|
var previousEquipmentId: String? = null
|
||||||
|
val relations = relationJson.objectsUnordered { group ->
|
||||||
|
requireKeys(group, setOf("equipment_id", "exercise_options"), "equipment relation")
|
||||||
|
val equipmentId = group.text("equipment_id")
|
||||||
|
check(equipmentId in equipmentIds && (previousEquipmentId == null || previousEquipmentId!! < equipmentId)) {
|
||||||
|
"equipment relation inconnue, dupliquée ou hors ordre"
|
||||||
|
}
|
||||||
|
previousEquipmentId = equipmentId
|
||||||
|
var previousOption: String? = null
|
||||||
|
group.getJSONArray("exercise_options").objectsUnordered { option ->
|
||||||
|
requireKeys(option, setOf("exercise_id", "relation_type", "configuration_label", "confidence", "source_refs"), "equipment exercise option")
|
||||||
|
val exerciseId = option.text("exercise_id")
|
||||||
|
val configuration = option.nullableString("configuration_label")
|
||||||
|
val key = "$exerciseId|${configuration ?: ""}"
|
||||||
|
val refs = option.strings("source_refs", true)
|
||||||
|
check(exerciseId in exerciseIds && option.text("relation_type") == "supported_exercise" &&
|
||||||
|
refs.all { it in refIds } && relationPairs.add(equipmentId to exerciseId) &&
|
||||||
|
(previousOption == null || previousOption!! < key)) { "relation équipement/exercice invalide" }
|
||||||
|
previousOption = key
|
||||||
|
EquipmentExerciseRelation(equipmentId, exerciseId, option.text("relation_type"),
|
||||||
|
configuration, confidence(option), refs)
|
||||||
|
}
|
||||||
|
}.flatten()
|
||||||
|
check(relations.none { it.exerciseId == "ex_617007f9-7420-4408-91b9-8ffb77900f13" }) {
|
||||||
|
"Seated Leg reste non résolu et distinct de la flexion de genou assise"
|
||||||
|
}
|
||||||
|
return TrainingKnowledgeCatalog(references, muscles, actions, patterns, exercises, equipment, relations, bodyZones)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confidence(item: JSONObject): KnowledgeConfidence = confidenceValues[item.text("confidence")] ?: error("confidence invalide")
|
private fun confidence(item: JSONObject): KnowledgeConfidence = confidenceValues[item.text("confidence")] ?: error("confidence invalide")
|
||||||
|
|
|
||||||
|
|
@ -655,6 +655,18 @@ class TrainlogRepository(
|
||||||
fun getMovementPatternKnowledge(patternId: String): MovementPatternKnowledge? = trainingKnowledge.getMovementPattern(patternId)
|
fun getMovementPatternKnowledge(patternId: String): MovementPatternKnowledge? = trainingKnowledge.getMovementPattern(patternId)
|
||||||
fun getScienceReference(refId: String): ScienceReference? = trainingKnowledge.getReference(refId)
|
fun getScienceReference(refId: String): ScienceReference? = trainingKnowledge.getReference(refId)
|
||||||
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = trainingKnowledge.getEquipmentKnowledge(equipmentId)
|
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = trainingKnowledge.getEquipmentKnowledge(equipmentId)
|
||||||
|
fun listEquipmentExerciseOptions(equipmentId: String): List<Pair<EquipmentExerciseRelation, ExerciseKnowledge>> =
|
||||||
|
trainingKnowledge.listRelationsForEquipment(equipmentId).mapNotNull { relation ->
|
||||||
|
trainingKnowledge.getExerciseKnowledge(relation.exerciseId)?.let { relation to it }
|
||||||
|
}
|
||||||
|
fun listEquipmentForKnownExercise(exerciseId: String): List<EquipmentKnowledge> =
|
||||||
|
resolveExerciseId(database.readableDatabase, exerciseId).let { canonicalExerciseId ->
|
||||||
|
// INVARIANT: retired creator IDs canonicalize exactly once before
|
||||||
|
// immutable equipment relations are queried; aliases are not extra options.
|
||||||
|
trainingKnowledge.getExerciseKnowledge(canonicalExerciseId)?.let {
|
||||||
|
trainingKnowledge.listEquipmentForExercise(canonicalExerciseId)
|
||||||
|
}.orEmpty()
|
||||||
|
}
|
||||||
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? = trainingKnowledge.getScientificBodyZoneMapping(exerciseId)
|
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? = trainingKnowledge.getScientificBodyZoneMapping(exerciseId)
|
||||||
fun listExercisesByMovementPattern(patternId: String): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMovementPattern(patternId)
|
fun listExercisesByMovementPattern(patternId: String): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMovementPattern(patternId)
|
||||||
fun listExercisesByMuscle(muscleId: String, role: MuscleRole): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMuscle(muscleId, role)
|
fun listExercisesByMuscle(muscleId: String, role: MuscleRole): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMuscle(muscleId, role)
|
||||||
|
|
@ -691,6 +703,20 @@ class TrainlogRepository(
|
||||||
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
|
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
|
||||||
EquipmentCatalog.search(listEquipment(), query)
|
EquipmentCatalog.search(listEquipment(), query)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTRACT: resolved machine exercises carry their Phase-1 legacy
|
||||||
|
* equipment context themselves. The UI may write this compatibility ID
|
||||||
|
* without asking the user for a second identity; NULL remains explicit for
|
||||||
|
* custom and unresolved exercises and is never inferred from a name.
|
||||||
|
*/
|
||||||
|
fun machineExerciseLegacyEquipmentId(exerciseId: String): String? =
|
||||||
|
database.readableDatabase.rawQuery(
|
||||||
|
"SELECT legacy_equipment_id FROM exercises WHERE exercise_id=?;",
|
||||||
|
arrayOf(resolveExerciseId(database.readableDatabase, exerciseId)),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getString(0)
|
||||||
|
}
|
||||||
|
|
||||||
/** Custom equipment is local user data, not an edit to the bundled manifest. */
|
/** Custom equipment is local user data, not an edit to the bundled manifest. */
|
||||||
fun createCustomEquipment(name: String): CreateEquipmentResult {
|
fun createCustomEquipment(name: String): CreateEquipmentResult {
|
||||||
val displayName = name.trim().replace(Regex("\\s+"), " ")
|
val displayName = name.trim().replace(Regex("\\s+"), " ")
|
||||||
|
|
@ -4376,7 +4402,11 @@ class TrainlogRepository(
|
||||||
val ownsTransaction = !db.inTransaction()
|
val ownsTransaction = !db.inTransaction()
|
||||||
if (ownsTransaction) db.beginTransactionNonExclusive()
|
if (ownsTransaction) db.beginTransactionNonExclusive()
|
||||||
return try {
|
return try {
|
||||||
val exercise = readExerciseProfileExact(db, exerciseId) ?: return null
|
// INVARIANT: retired creator IDs canonicalize once in this read
|
||||||
|
// snapshot, so profile, scientific metadata, relations, and actual
|
||||||
|
// history all describe the same exercise without alias duplicates.
|
||||||
|
val canonicalExerciseId = resolveExerciseId(db, exerciseId)
|
||||||
|
val exercise = readExerciseProfileExact(db, canonicalExerciseId) ?: return null
|
||||||
val direct = buildList {
|
val direct = buildList {
|
||||||
exercise.primaryZoneId?.let(::add)
|
exercise.primaryZoneId?.let(::add)
|
||||||
addAll(exercise.secondaryZoneIds)
|
addAll(exercise.secondaryZoneIds)
|
||||||
|
|
@ -4386,16 +4416,16 @@ class TrainlogRepository(
|
||||||
expanded += zoneId
|
expanded += zoneId
|
||||||
expanded += bodyZones.ancestors(zoneId).map { it.zoneId }
|
expanded += bodyZones.ancestors(zoneId).map { it.zoneId }
|
||||||
}
|
}
|
||||||
val knowledge = trainingKnowledge.getExerciseKnowledge(exerciseId)
|
val knowledge = trainingKnowledge.getExerciseKnowledge(canonicalExerciseId)
|
||||||
val compatible = knowledge?.equipmentIds.orEmpty().mapNotNull(trainingKnowledge::getEquipmentKnowledge)
|
val compatible = knowledge?.let { trainingKnowledge.listEquipmentForExercise(canonicalExerciseId) }.orEmpty()
|
||||||
val result = TrainingExerciseContext(
|
val result = TrainingExerciseContext(
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
persistedDirectZoneIds = direct,
|
persistedDirectZoneIds = direct,
|
||||||
persistedZoneIdsWithAncestors = bodyZones.zones.map { it.zoneId }.filter { it in expanded },
|
persistedZoneIdsWithAncestors = bodyZones.zones.map { it.zoneId }.filter { it in expanded },
|
||||||
knowledge = knowledge,
|
knowledge = knowledge,
|
||||||
compatibleEquipment = compatible,
|
compatibleEquipment = compatible,
|
||||||
latestExplicitMax = readLatestExplicitMax(db, exerciseId),
|
latestExplicitMax = readLatestExplicitMax(db, canonicalExerciseId),
|
||||||
recentPerformance = readExerciseOccurrencePage(db, exerciseId, occurrenceLimit, null, setPreviewLimit),
|
recentPerformance = readExerciseOccurrencePage(db, canonicalExerciseId, occurrenceLimit, null, setPreviewLimit),
|
||||||
)
|
)
|
||||||
if (ownsTransaction) db.setTransactionSuccessful()
|
if (ownsTransaction) db.setTransactionSuccessful()
|
||||||
result
|
result
|
||||||
|
|
@ -5783,7 +5813,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
12,
|
13,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -5911,6 +5941,11 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 12
|
version = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 13 && newVersion >= 13) {
|
||||||
|
migrateMachineExercisesToVersionThirteen(db)
|
||||||
|
version = 13
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"Unsupported Android DB upgrade " +
|
||||||
|
|
@ -6198,6 +6233,14 @@ private class TrainlogDatabaseHelper(
|
||||||
data_fields >= 0 AND
|
data_fields >= 0 AND
|
||||||
(data_fields & ~3) = 0
|
(data_fields & ~3) = 0
|
||||||
),
|
),
|
||||||
|
load_semantics TEXT
|
||||||
|
CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio')),
|
||||||
|
machine_variant TEXT,
|
||||||
|
machine_provenance TEXT,
|
||||||
|
scientific_profile_id TEXT,
|
||||||
|
science_state TEXT NOT NULL DEFAULT 'unresolved'
|
||||||
|
CHECK(science_state IN ('resolved','unresolved')),
|
||||||
|
legacy_equipment_id TEXT,
|
||||||
CHECK(
|
CHECK(
|
||||||
recording_mode !=
|
recording_mode !=
|
||||||
'continuous' OR
|
'continuous' OR
|
||||||
|
|
@ -6214,6 +6257,121 @@ private class TrainlogDatabaseHelper(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY: a fixed gym machine/movement is now the selectable performance
|
||||||
|
* identity, while equipment rows remain only compatibility/provenance.
|
||||||
|
* CONTRACT: v13 adds exercise metadata and the five frozen creator IDs.
|
||||||
|
* INVARIANT: only approved entry/exercise/equipment triples are repointed;
|
||||||
|
* sessions, entry IDs, child facts, targets and the durable draft survive.
|
||||||
|
*/
|
||||||
|
private fun migrateMachineExercisesToVersionThirteen(db: SQLiteDatabase) {
|
||||||
|
val columns = mutableSetOf<String>()
|
||||||
|
db.rawQuery("PRAGMA table_info(exercises);", null).use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndexOrThrow("name")
|
||||||
|
while (cursor.moveToNext()) columns += cursor.getString(nameIndex)
|
||||||
|
}
|
||||||
|
fun add(name: String, declaration: String) {
|
||||||
|
if (name !in columns) db.execSQL("ALTER TABLE exercises ADD COLUMN $declaration;")
|
||||||
|
}
|
||||||
|
add("load_semantics", "load_semantics TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'))")
|
||||||
|
add("machine_variant", "machine_variant TEXT")
|
||||||
|
add("machine_provenance", "machine_provenance TEXT")
|
||||||
|
add("scientific_profile_id", "scientific_profile_id TEXT")
|
||||||
|
add("science_state", "science_state TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'))")
|
||||||
|
add("legacy_equipment_id", "legacy_equipment_id TEXT")
|
||||||
|
seedMachineExercisesV13(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MachineSeed(
|
||||||
|
val exerciseId: String,
|
||||||
|
val oldName: String?,
|
||||||
|
val name: String,
|
||||||
|
val recording: String,
|
||||||
|
val tracking: String,
|
||||||
|
val fields: Int,
|
||||||
|
val load: String?,
|
||||||
|
val variant: String?,
|
||||||
|
val profile: String?,
|
||||||
|
val legacyEquipment: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun seedMachineExercisesV13(db: SQLiteDatabase) {
|
||||||
|
fun normalized(value: String): String = Normalizer.normalize(
|
||||||
|
value.trim().lowercase(Locale.ROOT), Normalizer.Form.NFD,
|
||||||
|
).replace(Regex("\\p{Mn}+"), "")
|
||||||
|
val rows = listOf(
|
||||||
|
MachineSeed("ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "Abduction de hanche assise", "Hip Abduction", "sets", "reps", 0, "external", "selectorized", "sp_hip_abduction_v1", "hip_abduction"),
|
||||||
|
MachineSeed("ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "Extension de genou assise", "Leg Extension", "sets", "reps", 0, "external", "selectorized", "sp_leg_extension_v1", "leg_extension"),
|
||||||
|
MachineSeed("ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "Extension du tronc", "Back Extension", "sets", "reps", 0, "external", "selectorized", "sp_back_extension_v1", "back_extension"),
|
||||||
|
MachineSeed("ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "Flexion de coude à la machine", "Arm Curl", "sets", "reps", 0, "external", "selectorized", "sp_arm_curl_v1", "arm_curl"),
|
||||||
|
MachineSeed("ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "Flexion de genou couchée", "Prone Leg Curl", "sets", "reps", 0, "external", "selectorized", "sp_prone_leg_curl_v1", "prone_leg_curl"),
|
||||||
|
MachineSeed("ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "Rotation du tronc à la machine", "Rotary Torso", "sets", "reps", 0, "external", "selectorized", "sp_rotary_torso_v1", "rotary_torso"),
|
||||||
|
MachineSeed("ex_33f79331-871c-4eed-babe-346e53a99070", "Tirage horizontal assis", "Seated Row", "sets", "reps", 0, "external", "selectorized", "sp_seated_row_v1", "seated_row"),
|
||||||
|
MachineSeed("ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "Tirage horizontal divergent assis", "Diverging Seated Row", "sets", "reps", 0, "external", "diverging", "sp_seated_row_v1", "diverging_seated_row"),
|
||||||
|
MachineSeed("ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "Tirage vertical divergent", "Diverging Lat Pulldown", "sets", "reps", 0, "external", "diverging", "sp_vertical_pull_v1", "diverging_lat_pulldown"),
|
||||||
|
MachineSeed("ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "Tirage vertical à la poulie", "Lat Pull", "sets", "reps", 0, "external", "selectorized", "sp_vertical_pull_v1", "lat_pull"),
|
||||||
|
MachineSeed("ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "Développé épaules convergent", "Converging Shoulder Press", "sets", "reps", 0, "external", "converging", "sp_shoulder_press_v1", "converging_shoulder_press"),
|
||||||
|
MachineSeed("ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "Flexion de genou assise", "Seated Leg Curl", "sets", "reps", 0, "external", "selectorized", "sp_seated_leg_curl_v1", "seated_leg_curl"),
|
||||||
|
MachineSeed("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Presse à cuisses", "Leg Press", "sets", "reps", 0, "external", "selectorized", "sp_leg_press_v1", "leg_press"),
|
||||||
|
MachineSeed("ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "Écarté inversé à la machine", "Rear Delt", "sets", "reps", 0, "external", "rear_delt", "sp_rear_delt_v1", "rear_delt_pec_fly"),
|
||||||
|
MachineSeed("ex_0e26c06f-a458-40a4-be20-4ed219ede30d", null, "Plate Loaded Leg Press", "sets", "reps", 0, "external", "plate_loaded", "sp_leg_press_v1", "plate_loaded_leg_press"),
|
||||||
|
MachineSeed("ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2", null, "Treadmill", "continuous", "duration", 3, "cardio", "treadmill", null, "treadmill"),
|
||||||
|
MachineSeed("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", null, "Pec Fly", "sets", "reps", 0, "external", "pec_fly", "sp_pec_fly_v1", "rear_delt_pec_fly"),
|
||||||
|
MachineSeed("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", null, "Chin Assist", "sets", "reps", 0, "assistance", "assisted", "sp_assisted_chin_v1", "assisted_dip_chin_machine"),
|
||||||
|
MachineSeed("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", null, "Dip Assist", "sets", "reps", 0, "assistance", "assisted", "sp_assisted_dip_v1", "assisted_dip_chin_machine"),
|
||||||
|
)
|
||||||
|
rows.forEach { item ->
|
||||||
|
val existing = exerciseIdentityRow(db, item.exerciseId)
|
||||||
|
if (existing == null) {
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields,load_semantics,machine_variant,scientific_profile_id,science_state,legacy_equipment_id) VALUES(?,?,?,?,?,?,?,?,?,?,?);",
|
||||||
|
arrayOf<Any?>(item.exerciseId, item.name, normalized(item.name), item.recording, item.tracking, item.fields, item.load, item.variant, item.profile, if (item.profile == null) "unresolved" else "resolved", item.legacyEquipment),
|
||||||
|
)
|
||||||
|
} else if (item.oldName != null) {
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE exercises SET name=?,normalized_name=?,load_semantics=?,machine_variant=?,scientific_profile_id=?,science_state='resolved',legacy_equipment_id=? WHERE exercise_id=? AND name=?;",
|
||||||
|
arrayOf<Any?>(item.name, normalized(item.name), item.load, item.variant, item.profile, item.legacyEquipment, item.exerciseId, item.oldName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hasBodyZoneTables = db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('exercise_body_zones','exercise_body_zone_sync');",
|
||||||
|
null,
|
||||||
|
).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) == 2 }
|
||||||
|
/* CONTRACT: BODY FOCUS consumes the existing exercise-owned relation.
|
||||||
|
* New performance identities reuse reviewed profile mappings without
|
||||||
|
* grouping their load histories. */
|
||||||
|
if (hasBodyZoneTables) {
|
||||||
|
db.execSQL("INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT n.id,z.zone_id,z.role FROM exercises n JOIN exercises o ON o.exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' JOIN exercise_body_zones z ON z.exercise_row_id=o.id WHERE n.exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';")
|
||||||
|
listOf(
|
||||||
|
Triple("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "chest", "primary"),
|
||||||
|
Triple("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "shoulders", "secondary"),
|
||||||
|
Triple("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "back", "primary"),
|
||||||
|
Triple("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "arms", "secondary"),
|
||||||
|
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "arms", "primary"),
|
||||||
|
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "chest", "secondary"),
|
||||||
|
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "shoulders", "secondary"),
|
||||||
|
).forEach { (exerciseId, zoneId, role) -> db.execSQL(
|
||||||
|
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,?,? FROM exercises WHERE exercise_id=?;",
|
||||||
|
arrayOf(zoneId, role, exerciseId),
|
||||||
|
) }
|
||||||
|
listOf(
|
||||||
|
"ex_0e26c06f-a458-40a4-be20-4ed219ede30d" to "thighs|glutes",
|
||||||
|
"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee" to "chest|shoulders",
|
||||||
|
"ex_58b8dfbc-92b2-4783-a449-9947a42480b8" to "back|arms",
|
||||||
|
"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf" to "arms|chest,shoulders",
|
||||||
|
).forEach { (exerciseId, state) -> db.execSQL(
|
||||||
|
"INSERT OR REPLACE INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,? FROM exercises WHERE exercise_id=?;",
|
||||||
|
arrayOf(state, exerciseId),
|
||||||
|
) }
|
||||||
|
}
|
||||||
|
db.execSQL("UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';")
|
||||||
|
fun equipmentRow(id: String) = "(SELECT id FROM equipment WHERE equipment_id='$id')"
|
||||||
|
db.execSQL("UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d') WHERE entry_id='sxe_draft_legacy_7' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde') AND equipment_row_id=${equipmentRow("plate_loaded_leg_press")};")
|
||||||
|
val treadmillEntries = listOf("sxe_093c1331-beaa-4b69-91b3-240292709be6", "sxe_f25142c8-455e-4346-9bfc-31d0989e275d", "sxe_f2242691-ba6c-48a0-b938-a1f744375d75", "sxe_9f8882f4-069e-49d5-8216-19d16b467e4a")
|
||||||
|
treadmillEntries.forEach { entry -> db.execSQL("UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2') WHERE entry_id=? AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d') AND equipment_row_id=${equipmentRow("treadmill")};", arrayOf(entry)) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun createSessionTables(
|
private fun createSessionTables(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ fun AndroidAppShell(
|
||||||
drawerContainerColor = colors.mantle,
|
drawerContainerColor = colors.mantle,
|
||||||
) {
|
) {
|
||||||
Text("TRAINLOG", modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp))
|
Text("TRAINLOG", modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp))
|
||||||
AppSection.entries.forEach { section ->
|
/* Equipment remains a compatibility store in schema v13, but
|
||||||
|
* it is no longer a normal product destination. */
|
||||||
|
AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
|
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
|
||||||
label = { Text(section.label) },
|
label = { Text(section.label) },
|
||||||
|
|
|
||||||
|
|
@ -608,13 +608,17 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
|
||||||
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
||||||
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||||
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||||
|
val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||||
|
val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge)
|
||||||
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
|
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
|
||||||
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||||
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
||||||
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
|
val equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId)
|
||||||
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
|
.map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId }
|
||||||
|
TrainlogInfo("Mouvements : ${actions.joinToString { it.displayNameFr }.ifEmpty { patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classés" } }}")
|
||||||
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
||||||
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||||
|
TrainlogInfo("Stabilisateurs : ${stabilizers.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||||
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
|
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
|
||||||
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
||||||
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
|
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
|
||||||
|
|
@ -695,7 +699,8 @@ fun ExerciseDetailScreen(
|
||||||
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
|
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
|
||||||
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
|
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
|
||||||
context.compatibleEquipment.forEach { equipment ->
|
context.compatibleEquipment.forEach { equipment ->
|
||||||
val name = listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
|
val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName
|
||||||
|
?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
|
||||||
TrainlogInfo(name)
|
TrainlogInfo(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,8 @@ internal fun BodyZoneHomeSection(
|
||||||
style = TrainlogTypography.small.copy(color = colors.text),
|
style = TrainlogTypography.small.copy(color = colors.text),
|
||||||
)
|
)
|
||||||
BasicText(
|
BasicText(
|
||||||
exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j · " +
|
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " +
|
||||||
exposureCountLabel(zone.secondaryWork7Days, "secondaire") + " sur 7 j",
|
recentWorkLabel(zone.secondaryWork7Days, "secondaire"),
|
||||||
style = TrainlogTypography.small.copy(color = colors.text),
|
style = TrainlogTypography.small.copy(color = colors.text),
|
||||||
)
|
)
|
||||||
BasicText(
|
BasicText(
|
||||||
|
|
@ -482,7 +482,7 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
|
||||||
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
|
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
|
||||||
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
|
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
|
||||||
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
|
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
|
||||||
else -> exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j"
|
else -> recentWorkLabel(zone.primaryWork7Days, "principal")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
|
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
|
||||||
|
|
@ -492,9 +492,8 @@ internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
|
||||||
zone.state.label
|
zone.state.label
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun exposureCountLabel(count: Int, adjective: String): String =
|
internal fun recentWorkLabel(count: Int, kind: String): String =
|
||||||
"$count ${if (count == 1) "exposition" else "expositions"} " +
|
"Travail $kind sur 7 j : $count"
|
||||||
if (count == 1) adjective else "${adjective}s"
|
|
||||||
|
|
||||||
private fun availableExerciseLabel(count: Int): String =
|
private fun availableExerciseLabel(count: Int): String =
|
||||||
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
|
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||||
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
|
import com.labfytools.trainlog.data.KnowledgeConfidence
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
|
@ -88,9 +89,10 @@ fun EquipmentScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
|
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
|
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
|
||||||
|
val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) }
|
||||||
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
|
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
|
||||||
if (entry == null) {
|
if (entry == null) {
|
||||||
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
|
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
|
||||||
|
|
@ -104,6 +106,19 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
|
||||||
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
|
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
|
||||||
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
|
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
|
||||||
}
|
}
|
||||||
|
TrainlogFrame("Exercices possibles", active = options.isNotEmpty()) {
|
||||||
|
if (options.isEmpty()) TrainlogInfo("Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.", colors.muted)
|
||||||
|
options.forEach { (relation, exercise) ->
|
||||||
|
val confidence = when (relation.confidence) {
|
||||||
|
KnowledgeConfidence.HIGH -> "élevée"
|
||||||
|
KnowledgeConfidence.MODERATE -> "modérée"
|
||||||
|
KnowledgeConfidence.UNCERTAIN -> "incertaine"
|
||||||
|
}
|
||||||
|
TrainlogAction(exercise.exerciseName,
|
||||||
|
listOfNotNull(relation.configurationLabel, "Confiance $confidence", "Sources vérifiées : ${relation.sourceRefs.size}").joinToString(" · "),
|
||||||
|
{ onOpenExercise(exercise.exerciseId) })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -841,10 +841,13 @@ private fun SessionExerciseForm(
|
||||||
|
|
||||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||||
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||||
|
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||||
|
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||||
|
}
|
||||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||||
var customEquipmentName by remember(key) { mutableStateOf("") }
|
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||||
var selectedEquipmentId by remember(key) {
|
var selectedEquipmentId by remember(key) {
|
||||||
mutableStateOf(initialForm.selectedEquipmentId)
|
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||||
}
|
}
|
||||||
var targetChoice by remember(key) {
|
var targetChoice by remember(key) {
|
||||||
mutableStateOf(if (initialPlan?.weightKg != null) ManualTargetChoice.KG
|
mutableStateOf(if (initialPlan?.weightKg != null) ManualTargetChoice.KG
|
||||||
|
|
@ -871,34 +874,38 @@ private fun SessionExerciseForm(
|
||||||
color = colors.accent,
|
color = colors.accent,
|
||||||
)
|
)
|
||||||
|
|
||||||
TrainlogInputField(
|
if (fixedEquipmentId == null) {
|
||||||
label = "Machine / équipement (optionnel)",
|
/* Legacy/custom/unresolved rows retain the compatibility selector;
|
||||||
value = equipmentSearch,
|
* resolved fixed machines never require a second user choice. */
|
||||||
onValueChange = { equipmentSearch = it },
|
TrainlogInputField(
|
||||||
)
|
label = "Contexte historique (optionnel)",
|
||||||
TrainlogInputField(
|
value = equipmentSearch,
|
||||||
label = "Nouvelle machine",
|
onValueChange = { equipmentSearch = it },
|
||||||
value = customEquipmentName,
|
)
|
||||||
onValueChange = { customEquipmentName = it },
|
TrainlogInputField(
|
||||||
)
|
label = "Nouveau contexte historique",
|
||||||
TrainlogAction(
|
value = customEquipmentName,
|
||||||
label = "Créer la machine",
|
onValueChange = { customEquipmentName = it },
|
||||||
description = "L'ajouter à votre catalogue puis la sélectionner pour cette entrée.",
|
)
|
||||||
accent = colors.success,
|
TrainlogAction(
|
||||||
onClick = {
|
label = "Créer le contexte",
|
||||||
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
description = "Compatibilité temporaire pour un exercice custom ou non résolu.",
|
||||||
is CreateEquipmentResult.Created -> {
|
accent = colors.success,
|
||||||
customEquipmentName = ""
|
onClick = {
|
||||||
equipmentRevision += 1
|
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
||||||
selectedEquipmentId = result.equipment.equipmentId
|
is CreateEquipmentResult.Created -> {
|
||||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
|
customEquipmentName = ""
|
||||||
|
equipmentRevision += 1
|
||||||
|
selectedEquipmentId = result.equipment.equipmentId
|
||||||
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
|
||||||
|
}
|
||||||
|
CreateEquipmentResult.Invalid -> error = "Donnez un nom de contexte valide."
|
||||||
|
CreateEquipmentResult.Conflict -> error = "Ce contexte existe déjà."
|
||||||
|
is CreateEquipmentResult.DatabaseError -> error = "Contexte non créé : ${result.message}"
|
||||||
}
|
}
|
||||||
CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide."
|
},
|
||||||
CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà."
|
)
|
||||||
is CreateEquipmentResult.DatabaseError -> error = "Machine non créée : ${result.message}"
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||||
val percentResult = remember(
|
val percentResult = remember(
|
||||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||||
|
|
@ -911,7 +918,7 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
if (selectedEquipment != null) {
|
if (selectedEquipment != null && fixedEquipmentId == null) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = "✓ ${selectedEquipment.displayName}",
|
label = "✓ ${selectedEquipment.displayName}",
|
||||||
description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." },
|
description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." },
|
||||||
|
|
@ -922,7 +929,7 @@ private fun SessionExerciseForm(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||||
description = equipment.labelName.ifBlank { equipment.type },
|
description = equipment.labelName.ifBlank { equipment.type },
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId)
|
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||||
AppRoute.Statistics -> StatisticsDashboard(repository)
|
AppRoute.Statistics -> StatisticsDashboard(repository)
|
||||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
|
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(12, scalar(db, "PRAGMA user_version"))
|
assertEquals(13, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,12 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(12, scalar(db, "PRAGMA user_version"))
|
assertEquals(13, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, snapshot(db).filterKeys { it != "exercise_aliases" })
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
|
val after = snapshot(db).filterKeys { it != "exercise_aliases" }
|
||||||
|
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync") }
|
||||||
|
.forEach { (table, rows) -> assertEquals(rows, after[table]) }
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ class BodyZonesTest {
|
||||||
db.execSQL("DROP TABLE exercise_body_zone_sync;")
|
db.execSQL("DROP TABLE exercise_body_zone_sync;")
|
||||||
db.execSQL("DROP TABLE exercise_body_zones;")
|
db.execSQL("DROP TABLE exercise_body_zones;")
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"INSERT INTO exercises VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," +
|
"INSERT INTO exercises(id,exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," +
|
||||||
"'Abdominal crunch','abdominal crunch','sets','reps',0);",
|
"'Abdominal crunch','abdominal crunch','sets','reps',0);",
|
||||||
)
|
)
|
||||||
val equipmentRow = db.rawQuery(
|
val equipmentRow = db.rawQuery(
|
||||||
|
|
@ -307,7 +307,9 @@ class BodyZonesTest {
|
||||||
db.execSQL("PRAGMA user_version=9;")
|
db.execSQL("PRAGMA user_version=9;")
|
||||||
}
|
}
|
||||||
val repository = TrainlogRepository(context, name)
|
val repository = TrainlogRepository(context, name)
|
||||||
val exercise = repository.listExercises().single()
|
val exercise = repository.listExercises().single {
|
||||||
|
it.exerciseId == "ex_9adf7566-f10c-443c-b63b-681d37665693"
|
||||||
|
}
|
||||||
assertEquals("core", exercise.primaryZoneId)
|
assertEquals("core", exercise.primaryZoneId)
|
||||||
val training = repository.getSessionDetail("se_zone_training")!!.exercises.single()
|
val training = repository.getSessionDetail("se_zone_training")!!.exercises.single()
|
||||||
assertEquals("sxe_zone_training", training.entryId)
|
assertEquals("sxe_zone_training", training.entryId)
|
||||||
|
|
@ -321,7 +323,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(12, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(13, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional real-copy v12 -> v13 production migration harness.
|
||||||
|
*
|
||||||
|
* WHY: synthetic fixtures cannot demonstrate that the production SQLiteOpenHelper
|
||||||
|
* accepts the user's complete database graph and preserves its durable draft.
|
||||||
|
* CONTRACT: the immutable source is copied before TrainlogRepository sees it;
|
||||||
|
* only the test-owned database is opened read/write, and the migrated artifact is
|
||||||
|
* exported to the explicit TRAINLOG_ANDROID_V13_OUTPUT path for host-side audit.
|
||||||
|
* INVARIANT: a second production open is a logical no-op at schema v13.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class RealAndroidV12MachineExerciseMigrationTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private val databaseName = "machine-exercise-real-v12.db"
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
context.deleteDatabase(databaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun realVersionTwelveCopyUsesProductionMigrationAndIsIdempotent() {
|
||||||
|
val source = File(System.getenv("TRAINLOG_ANDROID_V12_FIXTURE") ?: "")
|
||||||
|
val output = File(System.getenv("TRAINLOG_ANDROID_V13_OUTPUT") ?: "")
|
||||||
|
assumeTrue("TRAINLOG_ANDROID_V12_FIXTURE is not available", source.isFile)
|
||||||
|
assumeTrue("TRAINLOG_ANDROID_V13_OUTPUT is not available", output.parentFile?.isDirectory == true)
|
||||||
|
|
||||||
|
val databasePath = context.getDatabasePath(databaseName)
|
||||||
|
databasePath.parentFile?.mkdirs()
|
||||||
|
Files.copy(source.toPath(), databasePath.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { before ->
|
||||||
|
assertEquals(12, scalarInt(before, "PRAGMA user_version;"))
|
||||||
|
assertEquals("ok", scalarString(before, "PRAGMA integrity_check;"))
|
||||||
|
before.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogRepository(context, databaseName).let { repository ->
|
||||||
|
try {
|
||||||
|
assertTrue(repository.listExercises().isNotEmpty())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertValidVersionThirteen(databasePath)
|
||||||
|
val firstLogicalState = logicalDump(databasePath)
|
||||||
|
|
||||||
|
TrainlogRepository(context, databaseName).let { repository ->
|
||||||
|
try {
|
||||||
|
assertTrue(repository.listExercises().isNotEmpty())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertValidVersionThirteen(databasePath)
|
||||||
|
assertEquals(firstLogicalState, logicalDump(databasePath))
|
||||||
|
|
||||||
|
Files.copy(databasePath.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertValidVersionThirteen(databasePath: File) {
|
||||||
|
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||||
|
assertEquals(13, scalarInt(database, "PRAGMA user_version;"))
|
||||||
|
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
|
||||||
|
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logicalDump(databasePath: File): List<String> =
|
||||||
|
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
|
||||||
|
val tables = database.rawQuery(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;",
|
||||||
|
null,
|
||||||
|
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } }
|
||||||
|
buildList {
|
||||||
|
tables.forEach { table ->
|
||||||
|
/* Table names originate from sqlite_master, not external input. */
|
||||||
|
database.rawQuery("SELECT * FROM `$table` ORDER BY rowid;", null).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
add(buildString {
|
||||||
|
append(table)
|
||||||
|
repeat(cursor.columnCount) { column ->
|
||||||
|
append('|')
|
||||||
|
append(if (cursor.isNull(column)) "<NULL>" else cursor.getString(column))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarInt(database: SQLiteDatabase, sql: String): Int =
|
||||||
|
database.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getInt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalarString(database: SQLiteDatabase, sql: String): String =
|
||||||
|
database.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getString(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,23 @@ class TrainingExerciseContextTest {
|
||||||
assertNull(repository.getTrainingExerciseContext("ex_00000000-0000-4000-8000-000000000000"))
|
assertNull(repository.getTrainingExerciseContext("ex_00000000-0000-4000-8000-000000000000"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retiredAliasResolvesToCanonicalV2EquipmentContextExactlyOnce() {
|
||||||
|
seedFixture()
|
||||||
|
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
|
||||||
|
arrayOf(RETIRED_LEG_PRESS_ID, LEG_PRESS_ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = repository.getTrainingExerciseContext(RETIRED_LEG_PRESS_ID)!!
|
||||||
|
|
||||||
|
assertEquals(LEG_PRESS_ID, context.exercise.exerciseId)
|
||||||
|
assertEquals(LEG_PRESS_ID, context.knowledge!!.exerciseId)
|
||||||
|
assertEquals(1, context.compatibleEquipment.count { it.equipmentId == "leg_press" })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun continuousOccurrenceHasNoArtificialSetsAndInvalidPagingFails() {
|
fun continuousOccurrenceHasNoArtificialSetsAndInvalidPagingFails() {
|
||||||
seedFixture()
|
seedFixture()
|
||||||
|
|
@ -202,5 +219,6 @@ class TrainingExerciseContextTest {
|
||||||
companion object {
|
companion object {
|
||||||
private const val LEG_PRESS_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
|
private const val LEG_PRESS_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
|
||||||
private const val CONTINUOUS_ID = "ex_00000000-0000-4000-8000-000000000001"
|
private const val CONTINUOUS_ID = "ex_00000000-0000-4000-8000-000000000001"
|
||||||
|
private const val RETIRED_LEG_PRESS_ID = "ex_00000000-0000-4000-8000-000000000099"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class TrainingKnowledgeCatalogTest {
|
||||||
private val names = listOf(
|
private val names = listOf(
|
||||||
"science-references-v1.json", "muscles-v1.json", "joint-actions-v1.json",
|
"science-references-v1.json", "muscles-v1.json", "joint-actions-v1.json",
|
||||||
"movement-patterns-v1.json", "exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
"movement-patterns-v1.json", "exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
||||||
|
"equipment-exercise-relations-v2.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -38,6 +39,15 @@ class TrainingKnowledgeCatalogTest {
|
||||||
val multifunction = catalog.getEquipmentKnowledge("rear_delt_pec_fly")
|
val multifunction = catalog.getEquipmentKnowledge("rear_delt_pec_fly")
|
||||||
assertEquals(2, multifunction?.capabilities?.size)
|
assertEquals(2, multifunction?.capabilities?.size)
|
||||||
assertTrue(catalog.listCompatibleExercises("rear_delt_pec_fly").any { it.exerciseId == "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962" })
|
assertTrue(catalog.listCompatibleExercises("rear_delt_pec_fly").any { it.exerciseId == "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962" })
|
||||||
|
assertEquals(listOf("ex_4cd2433e-80b1-478a-b8df-73fc6ef80962"),
|
||||||
|
catalog.listExercisesForEquipment("rear_delt_pec_fly").map { it.exerciseId })
|
||||||
|
assertTrue(catalog.listExercisesForEquipment("functional_trainer").isEmpty())
|
||||||
|
assertTrue(catalog.listExercisesForEquipment("assisted_dip_chin_machine").isEmpty())
|
||||||
|
assertFalse(catalog.listExercisesForEquipment("seated_leg_curl")
|
||||||
|
.any { it.exerciseId == "ex_617007f9-7420-4408-91b9-8ffb77900f13" })
|
||||||
|
assertEquals(listOf("leg_press", "plate_loaded_leg_press"),
|
||||||
|
catalog.listEquipmentForExercise(legPress.exerciseId).map { it.equipmentId })
|
||||||
|
assertFalse(catalog.listEquipmentForBodyZone("chest").any { it.equipmentId == "rear_delt_pec_fly" })
|
||||||
assertNotNull(catalog.getMuscle("deltoid_posterior"))
|
assertNotNull(catalog.getMuscle("deltoid_posterior"))
|
||||||
assertNotNull(catalog.getJointAction("shoulder_horizontal_abduction"))
|
assertNotNull(catalog.getJointAction("shoulder_horizontal_abduction"))
|
||||||
assertNotNull(catalog.getReference(legPress.sourceRefs.first()))
|
assertNotNull(catalog.getReference(legPress.sourceRefs.first()))
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
repo.applyPcCatalogJson(stalePcCatalog.toString()),
|
repo.applyPcCatalogJson(stalePcCatalog.toString()),
|
||||||
)
|
)
|
||||||
val byId = repo.listExercises().associateBy { it.exerciseId }
|
val byId = repo.listExercises().associateBy { it.exerciseId }
|
||||||
assertEquals("Flexion de genou assise", byId.getValue(mappedId).name)
|
assertEquals("Seated Leg Curl", byId.getValue(mappedId).name)
|
||||||
assertEquals("Seated Leg", byId.getValue(distinctId).name)
|
assertEquals("Seated Leg", byId.getValue(distinctId).name)
|
||||||
assertEquals("My custom curl", byId.getValue(customId).name)
|
assertEquals("My custom curl", byId.getValue(customId).name)
|
||||||
|
|
||||||
|
|
@ -628,7 +628,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
repo.applyPcCatalogJson(stalePcCatalog.toString()),
|
repo.applyPcCatalogJson(stalePcCatalog.toString()),
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Flexion de genou assise",
|
"Seated Leg Curl",
|
||||||
repo.listExercises().single { it.exerciseId == mappedId }.name,
|
repo.listExercises().single { it.exerciseId == mappedId }.name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1651,7 +1651,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(12, cursor.getInt(0))
|
assertEquals(13, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1765,7 +1765,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
createVersionFourFixture(context.getDatabasePath(databaseName).path)
|
createVersionFourFixture(context.getDatabasePath(databaseName).path)
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
||||||
assertEquals(1, repo.listExercises().size)
|
assertEquals(20, repo.listExercises().size)
|
||||||
assertEquals(1, repo.listSessions().size)
|
assertEquals(1, repo.listSessions().size)
|
||||||
assertEquals(1, repo.listBodyObservations().size)
|
assertEquals(1, repo.listBodyObservations().size)
|
||||||
val loadedDraft = loadDraft(repo)
|
val loadedDraft = loadDraft(repo)
|
||||||
|
|
@ -1778,7 +1778,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(12, cursor.getInt(0))
|
assertEquals(13, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,11 @@ class BodyZoneHomePresentationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun exposureLabelsUseNaturalFrenchSingularAndPlural() {
|
@Test fun recentWorkLabelsAreNeutralFactualCounts() {
|
||||||
assertEquals("1 exposition principale", exposureCountLabel(1, "principale"))
|
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, "principal"))
|
||||||
assertEquals("2 expositions principales", exposureCountLabel(2, "principale"))
|
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, "principal"))
|
||||||
assertEquals("1 exposition secondaire", exposureCountLabel(1, "secondaire"))
|
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, "secondaire"))
|
||||||
assertEquals("3 expositions secondaires", exposureCountLabel(3, "secondaire"))
|
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, "secondaire"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
|
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
|
||||||
|
|
@ -61,7 +61,8 @@ class BodyZoneHomePresentationTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals("Priorité relative", homeStateLabel(status))
|
assertEquals("Priorité relative", homeStateLabel(status))
|
||||||
assertEquals("2 expositions principales sur 7 j", recommendationReason(status))
|
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status))
|
||||||
|
assertFalse(recommendationReason(status).contains("exposition"))
|
||||||
assertFalse(recommendationReason(status).contains("fait(s)"))
|
assertFalse(recommendationReason(status).contains("fait(s)"))
|
||||||
assertFalse(recommendationReason(status).contains("pondéré"))
|
assertFalse(recommendationReason(status).contains("pondéré"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ class BodyZoneHomeSemanticsTest {
|
||||||
compose.onNodeWithTag("body-map-front", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
compose.onNodeWithTag("body-map-front", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
||||||
compose.onNodeWithTag("body-map-back", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
compose.onNodeWithTag("body-map-back", useUnmergedTree = true).onChildren().assertCountEquals(0)
|
||||||
compose.onNodeWithText("1. Pectoraux · Priorité relative").assertExists()
|
compose.onNodeWithText("1. Pectoraux · Priorité relative").assertExists()
|
||||||
compose.onNodeWithText("1 exposition principale sur 7 j").assertExists()
|
compose.onNodeWithText("Travail principal sur 7 j : 1").assertExists()
|
||||||
listOf("fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
|
listOf("exposition principale sur 7 j", "exposition secondaire sur 7 j", "fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
|
||||||
compose.onAllNodesWithText(it, substring = true).assertCountEquals(0)
|
compose.onAllNodesWithText(it, substring = true).assertCountEquals(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
catalog/equipment-exercise-relations-v2.json
Normal file
23
catalog/equipment-exercise-relations-v2.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"format": "trainlog-equipment-exercise-relations-v2",
|
||||||
|
"version": 2,
|
||||||
|
"equipment_relations": [
|
||||||
|
{"equipment_id":"abdominal","exercise_options":[{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_abdominal_wall","openstax_trunk"]}]},
|
||||||
|
{"equipment_id":"arm_curl","exercise_options":[{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_upper"]}]},
|
||||||
|
{"equipment_id":"back_extension","exercise_options":[{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lee_2015","openstax_back","openstax_lower"]}]},
|
||||||
|
{"equipment_id":"converging_shoulder_press","exercise_options":[{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_deltoid","openstax_upper"]}]},
|
||||||
|
{"equipment_id":"diverging_lat_pulldown","exercise_options":[{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lehman_2004","openstax_upper","vigotsky_2018"]}]},
|
||||||
|
{"equipment_id":"diverging_seated_row","exercise_options":[{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["franke_2015","lehman_2004","openstax_upper"]}]},
|
||||||
|
{"equipment_id":"hip_abduction","exercise_options":[{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_gluteus_minimus","openstax_lower"]}]},
|
||||||
|
{"equipment_id":"hip_adduction","exercise_options":[{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_lower"]}]},
|
||||||
|
{"equipment_id":"lat_pull","exercise_options":[{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lehman_2004","openstax_upper","vigotsky_2018"]}]},
|
||||||
|
{"equipment_id":"leg_extension","exercise_options":[{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_lower"]}]},
|
||||||
|
{"equipment_id":"leg_press","exercise_options":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}]},
|
||||||
|
{"equipment_id":"plate_loaded_leg_press","exercise_options":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}]},
|
||||||
|
{"equipment_id":"prone_leg_curl","exercise_options":[{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["maeo_2021","openstax_lower"]}]},
|
||||||
|
{"equipment_id":"rear_delt_pec_fly","exercise_options":[{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","relation_type":"supported_exercise","configuration_label":"Oiseaux / Rear Delt","confidence":"moderate","source_refs":["franke_2015","life_fitness_catalog_2024","nih_deltoid","openstax_upper","vigotsky_2018"]}]},
|
||||||
|
{"equipment_id":"rotary_torso","exercise_options":[{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_abdominal_wall","openstax_back","openstax_trunk"]}]},
|
||||||
|
{"equipment_id":"seated_leg_curl","exercise_options":[{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["maeo_2021","openstax_lower"]}]},
|
||||||
|
{"equipment_id":"seated_row","exercise_options":[{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["franke_2015","lehman_2004","openstax_upper"]}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -3,20 +3,20 @@
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"names": [
|
"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_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_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Leg Extension","normalized_name":"leg extension","previous_display_names":["Extension de genou assise"]},
|
||||||
{"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_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotary Torso","normalized_name":"rotary torso","previous_display_names":["Rotation du tronc à la machine"]},
|
||||||
{"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_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Diverging Seated Row","normalized_name":"diverging seated row","previous_display_names":["Diverged seated row","Tirage horizontal divergent assis"]},
|
||||||
{"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_33f79331-871c-4eed-babe-346e53a99070","display_name":"Seated Row","normalized_name":"seated row","previous_display_names":["Tirage horizontal assis"]},
|
||||||
{"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_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Back Extension","normalized_name":"back extension","previous_display_names":["Extension du tronc"]},
|
||||||
{"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_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Rear Delt","normalized_name":"rear delt","previous_display_names":["Écarté inversé à la machine"]},
|
||||||
{"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_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Converging Shoulder Press","normalized_name":"converging shoulder press","previous_display_names":["Développé épaules convergent"]},
|
||||||
{"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_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Hip Abduction","normalized_name":"hip abduction","previous_display_names":["Abduction de hanche assise"]},
|
||||||
{"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_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_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Seated Leg Curl","normalized_name":"seated leg curl","previous_display_names":["Flexion de genou assise"]},
|
||||||
{"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_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Lat Pull","normalized_name":"lat pull","previous_display_names":["Tirage vertical à la poulie"]},
|
||||||
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Presse à cuisses","normalized_name":"presse à cuisses","previous_display_names":["Leg press"]},
|
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Leg Press","normalized_name":"leg press","previous_display_names":["Presse à cuisses"]},
|
||||||
{"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_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Diverging Lat Pulldown","normalized_name":"diverging lat pulldown","previous_display_names":["Tirage vertical divergent"]},
|
||||||
{"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_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Prone Leg Curl","normalized_name":"prone leg curl","previous_display_names":["Flexion de genou couchée"]},
|
||||||
{"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"]}
|
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Arm Curl","normalized_name":"arm curl","previous_display_names":["Flexion de coude à la machine"]}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
catalog/machine-exercises-v1.json
Normal file
26
catalog/machine-exercises-v1.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"format": "trainlog-machine-exercises",
|
||||||
|
"version": 1,
|
||||||
|
"exercises": [
|
||||||
|
{"exercise_id":"ex_0e26c06f-a458-40a4-be20-4ed219ede30d","display_name":"Plate Loaded Leg Press","load_semantics":"external","machine_variant":"plate_loaded","legacy_equipment_id":"plate_loaded_leg_press","scientific_profile_id":"sp_leg_press_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Leg Extension","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"leg_extension","scientific_profile_id":"sp_leg_extension_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotary Torso","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"rotary_torso","scientific_profile_id":"sp_rotary_torso_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Diverging Seated Row","load_semantics":"external","machine_variant":"diverging","legacy_equipment_id":"diverging_seated_row","scientific_profile_id":"sp_seated_row_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Seated Row","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"seated_row","scientific_profile_id":"sp_seated_row_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf","display_name":"Dip Assist","load_semantics":"assistance","machine_variant":"assisted","legacy_equipment_id":"assisted_dip_chin_machine","scientific_profile_id":"sp_assisted_dip_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Back Extension","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"back_extension","scientific_profile_id":"sp_back_extension_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Rear Delt","load_semantics":"external","machine_variant":"rear_delt","legacy_equipment_id":"rear_delt_pec_fly","scientific_profile_id":"sp_rear_delt_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee","display_name":"Pec Fly","load_semantics":"external","machine_variant":"pec_fly","legacy_equipment_id":"rear_delt_pec_fly","scientific_profile_id":"sp_pec_fly_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_58b8dfbc-92b2-4783-a449-9947a42480b8","display_name":"Chin Assist","load_semantics":"assistance","machine_variant":"assisted","legacy_equipment_id":"assisted_dip_chin_machine","scientific_profile_id":"sp_assisted_chin_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_617007f9-7420-4408-91b9-8ffb77900f13","display_name":"Seated Leg","load_semantics":null,"machine_variant":null,"legacy_equipment_id":null,"scientific_profile_id":null,"science_state":"unresolved"},
|
||||||
|
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Converging Shoulder Press","load_semantics":"external","machine_variant":"converging","legacy_equipment_id":"converging_shoulder_press","scientific_profile_id":"sp_shoulder_press_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Hip Abduction","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"hip_abduction","scientific_profile_id":"sp_hip_abduction_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Seated Leg Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"seated_leg_curl","scientific_profile_id":"sp_seated_leg_curl_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Lat Pull","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"lat_pull","scientific_profile_id":"sp_vertical_pull_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Leg Press","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"leg_press","scientific_profile_id":"sp_leg_press_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Diverging Lat Pulldown","load_semantics":"external","machine_variant":"diverging","legacy_equipment_id":"diverging_lat_pulldown","scientific_profile_id":"sp_vertical_pull_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Prone Leg Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"prone_leg_curl","scientific_profile_id":"sp_prone_leg_curl_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Arm Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"arm_curl","scientific_profile_id":"sp_arm_curl_v1","science_state":"resolved"},
|
||||||
|
{"exercise_id":"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2","display_name":"Treadmill","load_semantics":"cardio","machine_variant":"treadmill","legacy_equipment_id":"treadmill","scientific_profile_id":null,"science_state":"unresolved"}
|
||||||
|
]
|
||||||
|
}
|
||||||
21
catalog/scientific-profiles-v1.json
Normal file
21
catalog/scientific-profiles-v1.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"format": "trainlog-scientific-profiles",
|
||||||
|
"version": 1,
|
||||||
|
"profiles": [
|
||||||
|
{"scientific_profile_id":"sp_arm_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe"}},
|
||||||
|
{"scientific_profile_id":"sp_assisted_chin_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"assisted_dip_chin_machine","capability":"assisted chin pullup"}},
|
||||||
|
{"scientific_profile_id":"sp_assisted_dip_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"assisted_dip_chin_machine","capability":"assisted dip"}},
|
||||||
|
{"scientific_profile_id":"sp_back_extension_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835"}},
|
||||||
|
{"scientific_profile_id":"sp_hip_abduction_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b"}},
|
||||||
|
{"scientific_profile_id":"sp_leg_extension_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab"}},
|
||||||
|
{"scientific_profile_id":"sp_leg_press_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"}},
|
||||||
|
{"scientific_profile_id":"sp_pec_fly_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"rear_delt_pec_fly","capability":"pec fly"}},
|
||||||
|
{"scientific_profile_id":"sp_prone_leg_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5"}},
|
||||||
|
{"scientific_profile_id":"sp_rear_delt_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962"}},
|
||||||
|
{"scientific_profile_id":"sp_rotary_torso_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0"}},
|
||||||
|
{"scientific_profile_id":"sp_seated_leg_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"}},
|
||||||
|
{"scientific_profile_id":"sp_seated_row_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070"}},
|
||||||
|
{"scientific_profile_id":"sp_shoulder_press_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b"}},
|
||||||
|
{"scientific_profile_id":"sp_vertical_pull_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,6 @@ never silently rewritten as V3.
|
||||||
Accueil
|
Accueil
|
||||||
Séances
|
Séances
|
||||||
Exercices
|
Exercices
|
||||||
Équipements
|
|
||||||
Statistiques
|
Statistiques
|
||||||
Synchronisation
|
Synchronisation
|
||||||
Paramètres
|
Paramètres
|
||||||
|
|
@ -78,7 +77,8 @@ The section roots are:
|
||||||
Accueil durable-draft resume, manual capture, recent BODY ZONES exposure, concise local links
|
Accueil durable-draft resume, manual capture, recent BODY ZONES exposure, concise local links
|
||||||
Séances current session, programme a session, manual entry, completed sessions
|
Séances current session, programme a session, manual entry, completed sessions
|
||||||
Exercices catalogue, detail, create, and contextual edit
|
Exercices catalogue, detail, create, and contextual edit
|
||||||
Équipements catalogue, detail, and custom-equipment creation
|
Legacy equipment catalogue/detail routes remain compatibility-only and are
|
||||||
|
not exposed in the normal drawer.
|
||||||
Statistiques STATS_V1 dashboard, conservative graphs and detailed local drill-downs
|
Statistiques STATS_V1 dashboard, conservative graphs and detailed local drill-downs
|
||||||
Synchronisation existing request, result, and diagnostic workflow
|
Synchronisation existing request, result, and diagnostic workflow
|
||||||
Paramètres exchange-folder authorization and explicit PC-catalog refresh
|
Paramètres exchange-folder authorization and explicit PC-catalog refresh
|
||||||
|
|
@ -512,7 +512,7 @@ result.
|
||||||
|
|
||||||
Before applying the PC catalog or its V2 artifacts, Android applies
|
Before applying the PC catalog or its V2 artifacts, Android applies
|
||||||
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
|
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
|
||||||
before a received V2 association references them. Android schema v12 retains
|
before a received V2 association references them. Android schema v13 retains
|
||||||
the non-destructive v7 -> v8 migration required for `load_semantics = none`.
|
the non-destructive v7 -> v8 migration required for `load_semantics = none`.
|
||||||
It also applies `trainlog-exercise-aliases-v1.json` before catalog and session
|
It also applies `trainlog-exercise-aliases-v1.json` before catalog and session
|
||||||
reconciliation. The additive v11 → v12 alias table lets legacy exercise IDs
|
reconciliation. The additive v11 → v12 alias table lets legacy exercise IDs
|
||||||
|
|
@ -668,7 +668,8 @@ frozen desktop/Python normalization contract uses NFC, Unicode whitespace
|
||||||
collapse and case folding without accent removal. Existing Marche/Leg press
|
collapse and case folding without accent removal. Existing Marche/Leg press
|
||||||
data is unaffected, but changing this safely requires an explicit Android
|
data is unaffected, but changing this safely requires an explicit Android
|
||||||
schema migration that recomputes every normalized key and handles newly exposed
|
schema migration that recomputes every normalized key and handles newly exposed
|
||||||
collisions. It is not silently changed inside schema v12.
|
collisions. Schema v13 changes only the additive machine-exercise metadata and
|
||||||
|
the approved exact-ID historical contexts.
|
||||||
|
|
||||||
The bundled exercise/equipment relationship metadata is seeded and preserved,
|
The bundled exercise/equipment relationship metadata is seeded and preserved,
|
||||||
including during exercise-identity reconciliation, but the current equipment
|
including during exercise-identity reconciliation, but the current equipment
|
||||||
|
|
@ -720,3 +721,7 @@ full source and uncertainty contract is in [Training knowledge system V1](domain
|
||||||
The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending
|
The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending
|
||||||
V2. When importing the PC catalog, Android applies the versioned canonical
|
V2. When importing the PC catalog, Android applies the versioned canonical
|
||||||
exercise-name mapping for mapped IDs; unmapped user exercises remain editable.
|
exercise-name mapping for mapped IDs; unmapped user exercises remain editable.
|
||||||
|
Equipment Detail reads the shared V2 relation asset, shows verified possible
|
||||||
|
exercises with French confidence/evidence status, and opens Exercise Detail.
|
||||||
|
Exercise Detail shows exercise-owned muscles, movements, BODY ZONES, and
|
||||||
|
compatible physical equipment. Custom equipment receives no inferred anatomy.
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ It consumes core services for:
|
||||||
- synchronization log/detail display.
|
- synchronization log/detail display.
|
||||||
|
|
||||||
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
|
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
|
||||||
sections: Accueil, Séances, Exercices, Équipements, Statistiques,
|
sections: Accueil, Séances, Exercices, Statistiques,
|
||||||
Synchronisation and Paramètres. The controller owns route history, focus,
|
Synchronisation and Paramètres. The controller owns route history, focus,
|
||||||
overlays and transient leave guards; rendering owns no persistence, SQLite,
|
overlays and transient leave guards; rendering owns no persistence, SQLite,
|
||||||
transport, or synchronization decisions. Navigation and redraw alone never
|
transport, or synchronization decisions. Navigation and redraw alone never
|
||||||
|
|
@ -168,7 +168,7 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### Desktop
|
||||||
|
|
||||||
Desktop SQLite schema v12 is canonical long-term history. Its v9 -> v10
|
Desktop SQLite schema v13 is canonical long-term history. Its v9 -> v10
|
||||||
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
||||||
be finite `>= 0`; the column already existed and targets/max results retain
|
be finite `>= 0`; the column already existed and targets/max results retain
|
||||||
their strictly-positive contracts. `session_exercises`
|
their strictly-positive contracts. `session_exercises`
|
||||||
|
|
@ -203,7 +203,7 @@ exercise_body_zone_sync
|
||||||
|
|
||||||
### Android
|
### Android
|
||||||
|
|
||||||
Android has an independent local SQLite schema, currently v12. Completed and
|
Android has an independent local SQLite schema, currently v13. Completed and
|
||||||
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
||||||
resuming a completed Test max records its stable source session in the one
|
resuming a completed Test max records its stable source session in the one
|
||||||
durable draft.
|
durable draft.
|
||||||
|
|
@ -537,14 +537,17 @@ reconciliation.
|
||||||
|
|
||||||
## 14. Training knowledge V1 boundary
|
## 14. Training knowledge V1 boundary
|
||||||
|
|
||||||
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Six versioned JSON
|
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Versioned JSON
|
||||||
catalogs under `catalog/` are the only authored scientific source; generated C
|
catalogs under `catalog/` are the only authored scientific source; generated C
|
||||||
data and Android asset loading derive from them. They contain cited anatomy,
|
data and Android asset loading derive from them. They contain cited anatomy,
|
||||||
movement, exercise and equipment knowledge, not user history. The generated
|
movement, exercise and equipment knowledge, not user history. The generated
|
||||||
knowledge audit is evidence output, not an editable source.
|
knowledge audit is evidence output, not an editable source.
|
||||||
|
|
||||||
The desktop `training_knowledge.h` API exposes immutable catalog records and
|
The desktop `training_knowledge.h` API exposes immutable catalog records and
|
||||||
stable-ID queries. `training_context.h` combines one exact persisted exercise
|
stable-ID queries. `EQUIPMENT_KNOWLEDGE_V2` adds a separate immutable relation artifact and bounded
|
||||||
|
equipment/exercise/zone queries. Zone queries are strictly zone → resolved
|
||||||
|
exercise → equipment relation; equipment labels never infer anatomy.
|
||||||
|
`training_context.h` combines one exact persisted exercise
|
||||||
with its stored BODY ZONE relations, optional science, compatible equipment,
|
with its stored BODY ZONE relations, optional science, compatible equipment,
|
||||||
latest explicit maximum, and bounded occurrence/set history under one read
|
latest explicit maximum, and bounded occurrence/set history under one read
|
||||||
snapshot. Android provides the corresponding catalog and repository context.
|
snapshot. Android provides the corresponding catalog and repository context.
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ Implemented:
|
||||||
F7 actions, and restored focus after overlays/routes);
|
F7 actions, and restored focus after overlays/routes);
|
||||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||||
minimum terminal;
|
minimum terminal;
|
||||||
- SQLite schema v12, with stable ordered `session_exercises.entry_id`,
|
- SQLite schema v13, with stable ordered `session_exercises.entry_id`,
|
||||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||||
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
||||||
|
|
@ -111,7 +111,7 @@ Implemented:
|
||||||
Primary navigation is now shared with Android:
|
Primary navigation is now shared with Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
|
Accueil | Séances | Exercices | Statistiques | Synchronisation | Paramètres
|
||||||
```
|
```
|
||||||
|
|
||||||
`Séances` contains the current draft, the existing generator, manual entry,
|
`Séances` contains the current draft, the existing generator, manual entry,
|
||||||
|
|
@ -181,14 +181,21 @@ top bar and content host keep navigation separate from page ownership.
|
||||||
|
|
||||||
## Training knowledge V1
|
## Training knowledge V1
|
||||||
|
|
||||||
The implemented read-only training-knowledge layer loads six versioned JSON
|
The implemented read-only training-knowledge layer loads shared versioned JSON
|
||||||
catalogs as the sole authored scientific source, generates the immutable C
|
catalogs as the sole authored scientific source, generates the immutable C
|
||||||
catalog representation, and loads the same assets on Android. It has no
|
catalog representation, and loads the same assets on Android. It has no
|
||||||
database migration, no auto-seeding, and no synchronization artifact. The
|
database migration, no auto-seeding, and no synchronization artifact. The
|
||||||
desktop database remains schema v12 and Android remains schema v12.
|
desktop and Android databases are schema v13. Schema v13 is additive: exercise
|
||||||
|
rows own machine-facing load semantics, optional legacy equipment provenance,
|
||||||
|
an optional scientific profile, and an explicit resolved/unresolved science
|
||||||
|
state. Legacy equipment tables and occurrence columns remain intact.
|
||||||
|
|
||||||
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
|
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
|
||||||
source-linked science lookups and resolved-candidate filters. Desktop
|
source-linked science lookups and resolved-candidate filters. The additive
|
||||||
|
`equipment-exercise-relations-v2.json` catalog is the canonical
|
||||||
|
equipment-to-exercise compatibility source. Equipment owns physical identity,
|
||||||
|
load semantics, configuration labels, confidence, and source references only;
|
||||||
|
exercise knowledge exclusively owns muscles, movements, and BODY ZONES. Desktop
|
||||||
`training_context.h` and Android `TrainlogRepository` compose one real runtime
|
`training_context.h` and Android `TrainlogRepository` compose one real runtime
|
||||||
exercise with its persisted zones, compatible equipment, latest explicit MAX,
|
exercise with its persisted zones, compatible equipment, latest explicit MAX,
|
||||||
and bounded chronological occurrence/set history. Persisted zones remain
|
and bounded chronological occurrence/set history. Persisted zones remain
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,18 @@
|
||||||
# Desktop database
|
# Desktop database
|
||||||
|
|
||||||
|
## Schema v13: machine-specific exercises
|
||||||
|
|
||||||
|
Migration v12→v13 is additive and transactional. It adds exercise-owned load
|
||||||
|
and provenance metadata, installs the five frozen UUIDv4 identities, applies
|
||||||
|
the approved same-ID display renames, and repoints only manifest-listed
|
||||||
|
`entry_id` + source exercise + source equipment triples. Session IDs, entry
|
||||||
|
IDs, positions, child set/MAX/continuous rows, notes, targets, timestamps,
|
||||||
|
aliases, and body observations are unchanged. Reopening v13 is idempotent.
|
||||||
|
|
||||||
|
Equipment tables and occurrence equipment columns remain in Phase 1 for sync,
|
||||||
|
durable drafts, old imports, and explicit historical provenance. Schema cleanup
|
||||||
|
is a later, separately versioned migration.
|
||||||
|
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -569,7 +582,7 @@ Desktop schema v11 and Android schema v10 then add only body-zone relation and
|
||||||
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
|
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
|
||||||
neither migration changes the occurrence/equipment/MAX graph described above.
|
neither migration changes the occurrence/equipment/MAX graph described above.
|
||||||
|
|
||||||
Desktop and Android schema v12 add only the durable flattened
|
Desktop and Android schema v12 added only the durable flattened
|
||||||
`exercise_aliases` mapping. It resolves retired creator IDs to a live canonical
|
`exercise_aliases` mapping. It resolves retired creator IDs to a live canonical
|
||||||
exercise during catalog and companion reconciliation without changing any
|
exercise during catalog and companion reconciliation without changing any
|
||||||
session, set, continuous, MAX, equipment, body-zone or planning wire shape.
|
session, set, continuous, MAX, equipment, body-zone or planning wire shape.
|
||||||
|
|
@ -600,7 +613,7 @@ history.
|
||||||
## 14. Training knowledge read boundary
|
## 14. Training knowledge read boundary
|
||||||
|
|
||||||
Training Knowledge V1 adds no table, migration, seed data or synchronization
|
Training Knowledge V1 adds no table, migration, seed data or synchronization
|
||||||
artifact. The desktop database remains schema v12. Read-only context assembly
|
artifact. Desktop schema v13 retains it unchanged. Read-only context assembly
|
||||||
joins an exact existing exercise with its persisted BODY ZONE relations,
|
joins an exact existing exercise with its persisted BODY ZONE relations,
|
||||||
occurrence history, raw sets, actual equipment and latest explicit MAX, then
|
occurrence history, raw sets, actual equipment and latest explicit MAX, then
|
||||||
optionally attaches immutable catalog knowledge. Missing catalog knowledge is
|
optionally attaches immutable catalog knowledge. Missing catalog knowledge is
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
# Exercise data model
|
# Exercise data model
|
||||||
|
|
||||||
|
## Machine-specific performance identity (schema v13)
|
||||||
|
|
||||||
|
Normal selection is exercise-centric: a fixed machine/movement is represented
|
||||||
|
by one stable `exercise_id`. `Leg Press` and `Plate Loaded Leg Press`, or
|
||||||
|
`Rear Delt` and `Pec Fly`, therefore keep independent occurrences, MAX results,
|
||||||
|
and working-load series even when they share a frame or scientific profile.
|
||||||
|
|
||||||
|
The additive exercise metadata is `load_semantics` (`none`, `external`,
|
||||||
|
`assistance`, `bodyweight`, or `cardio`), nullable `machine_variant` and
|
||||||
|
`machine_provenance`, nullable `scientific_profile_id`, explicit
|
||||||
|
`science_state`, and nullable `legacy_equipment_id`. Null metadata is valid for
|
||||||
|
custom or unresolved exercises and must never be inferred from the name.
|
||||||
|
Occurrence `load_mode` remains a separate recorded fact.
|
||||||
|
|
||||||
|
Scientific profiles are versioned knowledge identities. They may be shared by
|
||||||
|
several exercises to reuse movement, joint-action, muscle, evidence, and BODY
|
||||||
|
ZONE knowledge. They are never a performance grouping key. Seated Leg and
|
||||||
|
Treadmill deliberately have no profile in V1.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -356,3 +375,7 @@ independently verified; final validation passed.
|
||||||
Exercise names are mutable display metadata. The versioned canonical-name map
|
Exercise names are mutable display metadata. The versioned canonical-name map
|
||||||
applies only to listed stable IDs and never makes two distinct exercises,
|
applies only to listed stable IDs and never makes two distinct exercises,
|
||||||
equipment contexts, MAX observations, or historical occurrences equivalent.
|
equipment contexts, MAX observations, or historical occurrences equivalent.
|
||||||
|
Equipment compatibility is an additive relation between a physical equipment
|
||||||
|
ID and an already-known canonical exercise UUID. It is not exercise identity,
|
||||||
|
anatomy, occurrence history, or MAX context. Generic and custom equipment stay
|
||||||
|
unassociated unless an explicit verified relation is representable.
|
||||||
|
|
|
||||||
59
docs/reviews/equipment_knowledge_v2_audit.md
Normal file
59
docs/reviews/equipment_knowledge_v2_audit.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# EQUIPMENT_KNOWLEDGE_V2 audit
|
||||||
|
|
||||||
|
Date: 2026-09-11. Scope: all 41 records in `equipment-knowledge-v1.json`, checked
|
||||||
|
against the supplied manifest, current UUID exercise knowledge, and the canonical
|
||||||
|
V2 relations. `S` means supplied; `C` means custom. Sources are existing catalog
|
||||||
|
references only. An empty option is intentional and never implies anatomy.
|
||||||
|
|
||||||
|
| Equipment (ID — name) | Origin / load | Classification | Canonical exercise options; confidence; evidence | Missing question |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| abdominal — Abdominal / Crunch | S / external | RESOLVED_SINGLE_EXERCISE | Abdominal crunch; moderate; `nih_abdominal_wall`, `openstax_trunk` | Local linkage/trajectory |
|
||||||
|
| abdominal_bench — Banc à abdominaux | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Crunch or sit-up execution? |
|
||||||
|
| arm_curl — Curl biceps | S / external | RESOLVED_SINGLE_EXERCISE | Arm curl; high; `openstax_upper` | Grip/cam/setup |
|
||||||
|
| assisted_dip_chin_machine — Chin / Dip Assist | S / assistance | PARTIALLY_RESOLVED | none: assisted dip and assisted chin are distinct capabilities, but neither has a resolved UUID exercise record | Create/identify each canonical exercise separately |
|
||||||
|
| back_extension — Extension lombaires | S / external | RESOLVED_SINGLE_EXERCISE | Back extension; moderate; `lee_2015`, `openstax_back`, `openstax_lower` | Local axis/ROM |
|
||||||
|
| battle_ropes — Cordes ondulatoires | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Actual exercise protocol? |
|
||||||
|
| converging_shoulder_press — Presse épaules | S / external | RESOLVED_SINGLE_EXERCISE | Converging Shoulder Press; moderate; `nih_deltoid`, `openstax_upper` | Local handle/path |
|
||||||
|
| diverging_lat_pulldown — Tirage vertical divergent | S / external | RESOLVED_SINGLE_EXERCISE | Diverging lat pulldown; moderate; `lehman_2004`, `openstax_upper`, `vigotsky_2018` | Grip/path |
|
||||||
|
| diverging_seated_row — Tirage horizontal divergent | S / external | RESOLVED_SINGLE_EXERCISE | Diverged seated row; moderate; `franke_2015`, `lehman_2004`, `openstax_upper` | Grip/path |
|
||||||
|
| dual_adjustable_pulley — Double poulie | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise and configuration? |
|
||||||
|
| dumbbells — Haltères | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
|
||||||
|
| eq_0a462c1e-9fb6-4fd7-b0a2-53a0e86f33c6 — custom chest press | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
|
||||||
|
| eq_3f987a36-bbb4-4e29-9c9f-1f201f1596e0 — custom abdominal | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
|
||||||
|
| eq_c660cd61-5b17-498e-8d51-9eafcf7e2731 — custom converting press | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
|
||||||
|
| functional_trainer — Functional Trainer | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise/configuration? |
|
||||||
|
| hip_abduction — Abducteurs | S / external | RESOLVED_SINGLE_EXERCISE | Hip abduction; moderate; `nih_gluteus_minimus`, `openstax_lower` | Local setup |
|
||||||
|
| hip_adduction — Adducteurs | S / external | RESOLVED_SINGLE_EXERCISE | Hip adduction; high; `openstax_lower` | Local setup |
|
||||||
|
| indoor_cycle — Vélo indoor | S / cardio | PARTIALLY_RESOLVED | none | Which canonical continuous exercise? |
|
||||||
|
| kettlebells — Kettlebells | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
|
||||||
|
| lat_pull — Tirage vertical | S / external | RESOLVED_SINGLE_EXERCISE | Lat pull; moderate; `lehman_2004`, `openstax_upper`, `vigotsky_2018` | Grip/path |
|
||||||
|
| lat_pulldown — Lat Pulldown | S / external | PARTIALLY_RESOLVED | none | Distinguish from current Lat pull identity |
|
||||||
|
| leg_extension — Extension des jambes | S / external | RESOLVED_SINGLE_EXERCISE | Leg extension; high; `openstax_lower` | Local axis/setup |
|
||||||
|
| leg_press — Presse à cuisses | S / external | RESOLVED_SINGLE_EXERCISE | Leg press / Presse à cuisses; moderate; `martin_fuentes_2020`, `openstax_lower`, `vigotsky_2018` | Local geometry |
|
||||||
|
| low_row — Tirage horizontal bas | S / external | PARTIALLY_RESOLVED | none | Does an existing row exercise identity match exactly? |
|
||||||
|
| medicine_ball — Medicine Ball | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
|
||||||
|
| perfect_squat — Squat guidé | S / external | PARTIALLY_RESOLVED | none | Canonical exercise identity and local mechanics? |
|
||||||
|
| plate_loaded_leg_press — Presse à cuisses à disques | S / external | RESOLVED_SINGLE_EXERCISE | Leg press / Presse à cuisses; moderate; same existing leg-press sources | Local geometry/load equivalence |
|
||||||
|
| prone_leg_curl — Leg curl allongé | S / external | RESOLVED_SINGLE_EXERCISE | Prone leg curl; high; `maeo_2021`, `openstax_lower` | Local setup |
|
||||||
|
| rear_delt_pec_fly — Oiseaux / Pec Fly | S / external | PARTIALLY_RESOLVED | Rear Delt only, configuration “Oiseaux / Rear Delt”; moderate; existing rear-delt sources | No canonical chest-fly UUID exists; do not fabricate one |
|
||||||
|
| recumbent_bike — Vélo semi-allongé | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
|
||||||
|
| rotary_torso — Rotation du buste | S / external | RESOLVED_SINGLE_EXERCISE | Rotary torso; moderate; `nih_abdominal_wall`, `openstax_back`, `openstax_trunk` | Local axis/ROM |
|
||||||
|
| rowing_machine — Rameur | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous rowing identity? |
|
||||||
|
| seated_dip — Dips assis | S / external | PARTIALLY_RESOLVED | none | Canonical seated-dip identity? |
|
||||||
|
| seated_leg_curl — Leg curl assis | S / external | RESOLVED_SINGLE_EXERCISE | Seated leg curl / Flexion de genou assise; high; `maeo_2021`, `openstax_lower` | Local setup |
|
||||||
|
| seated_row — Tirage horizontal assis | S / external | RESOLVED_SINGLE_EXERCISE | Seated row; moderate; `franke_2015`, `lehman_2004`, `openstax_upper` | Grip/path |
|
||||||
|
| stair_climber — Escalier | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
|
||||||
|
| suspension_trainer — Sangles | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Actual exercise/setup? |
|
||||||
|
| treadmill — Tapis de course | S / cardio | PARTIALLY_RESOLVED | none | Walk, run, incline, and canonical identity? |
|
||||||
|
| upright_bike — Vélo droit | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
|
||||||
|
| vertical_chest_press — Presse pectoraux | S / external | PARTIALLY_RESOLVED | none | Existing chest-press records are conditional/custom, not this supplied identity |
|
||||||
|
| weighted_bag — Sac lesté | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
|
||||||
|
|
||||||
|
`Seated Leg` (`ex_617007f9-7420-4408-91b9-8ffb77900f13`) remains explicitly
|
||||||
|
unresolved in V2 and receives no relation. It is not “Flexion de genou assise”
|
||||||
|
and receives no MAX, history, equipment, or anatomy transfer.
|
||||||
|
|
||||||
|
Future identification is documentation only: manufacturer/model/name plus an
|
||||||
|
optional photo, pictogram, and notes may create a proposal; the user verifies
|
||||||
|
the proposed identity before it is persisted. V2 performs no OCR or image
|
||||||
|
recognition and the current schema has no custom-equipment link field.
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
|
## MACHINE_EXERCISE_MODEL_V1 — implementation awaiting human review
|
||||||
|
|
||||||
|
Phase 1 introduces additive schema v13, machine-facing exercise identities,
|
||||||
|
exercise-owned load semantics, reusable scientific profile references, exact
|
||||||
|
manifest splits, and exercise-centric Android/TUI navigation. Real database
|
||||||
|
migration remains gated on copy validation and human review.
|
||||||
|
|
||||||
|
`EQUIPMENT_SCHEMA_CLEANUP_V1` remains a later phase after real-world
|
||||||
|
validation; it may remove obsolete legacy tables, fields, companions, and UI
|
||||||
|
only with a new explicit migration and compatibility decision.
|
||||||
|
|
||||||
This file is the canonical product roadmap for Trainlog.
|
This file is the canonical product roadmap for Trainlog.
|
||||||
|
|
||||||
Historical implementation detail belongs in Git history and `docs/reviews`.
|
Historical implementation detail belongs in Git history and `docs/reviews`.
|
||||||
|
|
@ -479,3 +490,7 @@ Exact parsed instants govern boundaries and ordering. Assistance, missing values
|
||||||
and incomparable equipment contexts are never coerced into progress values.
|
and incomparable equipment contexts are never coerced into progress values.
|
||||||
Malformed legacy timestamps are skipped by the projection with a visible warning
|
Malformed legacy timestamps are skipped by the projection with a visible warning
|
||||||
while valid statistics remain available.
|
while valid statistics remain available.
|
||||||
|
Future equipment identification may collect manufacturer, model, name, photo,
|
||||||
|
pictogram, and notes to form a proposal which the user must verify before
|
||||||
|
persistence. OCR/image recognition and a custom-equipment relation schema are
|
||||||
|
not part of EQUIPMENT_KNOWLEDGE_V2.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
# Synchronization exchange
|
# Synchronization exchange
|
||||||
|
|
||||||
|
## Machine-exercise Phase 1 compatibility
|
||||||
|
|
||||||
|
Desktop and Android schema v13 retain mobile export V3, readable V1/V2 imports,
|
||||||
|
equipment definitions V1, equipment associations V2, exercise aliases V1, and
|
||||||
|
the BODY ZONES companion without wire-format changes. Machine metadata is not
|
||||||
|
silently added to a frozen artifact: stable exercise IDs and canonical names
|
||||||
|
travel through the existing catalog, while v13 metadata stays local until a
|
||||||
|
dedicated versioned companion is required. Legacy equipment IDs may still be
|
||||||
|
written for fixed supplied machines as compatibility provenance.
|
||||||
|
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -568,6 +578,16 @@ previously known choice. Unknown canonical IDs, unknown entries, ambiguous
|
||||||
identities and divergent values reject processing explicitly; an unknown
|
identities and divergent values reject processing explicitly; an unknown
|
||||||
equipment reference is never silently changed to null.
|
equipment reference is never silently changed to null.
|
||||||
|
|
||||||
|
After `MACHINE_EXERCISE_MODEL_V1`, a pre-v13 association companion can outlive
|
||||||
|
the five approved completed-occurrence splits even though the active mobile V3
|
||||||
|
snapshot already carries the new identities. Desktop accepts that stale
|
||||||
|
corroborating `exercise_id` only for the frozen `(session_id, entry_id, old ID,
|
||||||
|
new ID)` split table and only when the mobile V3 snapshot imported in the same
|
||||||
|
run proves the exact new local identity. Equipment state must still be equal.
|
||||||
|
Every other exercise mismatch remains a conflict. The old Leg Press and Marche
|
||||||
|
IDs are not aliases for their machine-specific siblings, and draft occurrences
|
||||||
|
are outside this completed-history compatibility rule.
|
||||||
|
|
||||||
V2 association semantics are unchanged. A custom equipment ID is accepted only
|
V2 association semantics are unchanged. A custom equipment ID is accepted only
|
||||||
after its definition V1 artifact has reconciled it on the receiving side.
|
after its definition V1 artifact has reconciled it on the receiving side.
|
||||||
Unknown IDs, unknown entries, ambiguous identities, and definition conflicts
|
Unknown IDs, unknown entries, ambiguous identities, and definition conflicts
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ Historical validation checkpoint (the current desktop suite is 48/48):
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
||||||
Android schema v4 introduced one durable active draft; the current additive
|
Android schema v4 introduced one durable active draft; the current additive
|
||||||
chain reaches schema v12 without clearing completed history or the draft. The
|
chain reaches schema v13 without clearing completed history or the draft. The
|
||||||
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
||||||
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
||||||
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
|
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
|
||||||
|
|
@ -630,6 +630,13 @@ This exercises real artifacts and production import/export code on copies. It
|
||||||
does not substitute for a final direct-MTP run when USB ownership or sandbox
|
does not substitute for a final direct-MTP run when USB ownership or sandbox
|
||||||
permissions prevent libmtp access.
|
permissions prevent libmtp access.
|
||||||
|
|
||||||
|
`tests/test_equipment_associations_exchange.py` additionally covers the
|
||||||
|
bounded v13 stale-companion bridge for the five frozen machine-exercise splits.
|
||||||
|
Plate Loaded Leg Press and Treadmill pass only with matching current mobile V3
|
||||||
|
proof; generic Marche, the durable Marche+treadmill draft, Seated Leg, and Rear
|
||||||
|
Delt NULL provenance remain unchanged; an unrelated exercise mismatch still
|
||||||
|
fails; and replay is idempotent.
|
||||||
|
|
||||||
For this checkpoint, the USB probe discovered the connected Samsung interface
|
For this checkpoint, the USB probe discovered the connected Samsung interface
|
||||||
but libmtp failed at `libusb_open()`. The canonical desktop DB was also
|
but libmtp failed at `libusb_open()`. The canonical desktop DB was also
|
||||||
read-only to the sandbox; the first attempted definitions import failed before
|
read-only to the sandbox; the first attempted definitions import failed before
|
||||||
|
|
@ -688,3 +695,7 @@ for Training Knowledge V1.
|
||||||
Shell/naming closeout coverage includes real-PTY shell behavior, canonical
|
Shell/naming closeout coverage includes real-PTY shell behavior, canonical
|
||||||
name export/import replay on temporary databases, and preservation of sets,
|
name export/import replay on temporary databases, and preservation of sets,
|
||||||
MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises.
|
MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises.
|
||||||
|
EQUIPMENT_KNOWLEDGE_V2 validation covers relation envelopes, canonical UUID and
|
||||||
|
equipment references, enum/source integrity, uniqueness and stable ordering,
|
||||||
|
the unresolved Seated Leg invariant, generic/custom non-inference, and generated
|
||||||
|
C/Android query parity for leg press, combo equipment, and chest-zone derivation.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Desktop SQLite is the canonical long-term history.
|
||||||
The persistent shell has seven root sections:
|
The persistent shell has seven root sections:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
|
Accueil | Séances | Exercices | Statistiques | Synchronisation | Paramètres
|
||||||
```
|
```
|
||||||
|
|
||||||
`Séances` contains **Séance en cours**, **Programmer une séance**, **Nouvelle
|
`Séances` contains **Séance en cours**, **Programmer une séance**, **Nouvelle
|
||||||
|
|
@ -281,7 +281,9 @@ catalog.
|
||||||
|
|
||||||
## 7. Equipment
|
## 7. Equipment
|
||||||
|
|
||||||
`Équipements` (direct alias `4/F4`) provides supplied-equipment browsing,
|
The legacy Equipment catalogue is no longer a normal top-level workflow.
|
||||||
|
Its storage and compatibility APIs remain available for old history and peers.
|
||||||
|
Historically, `Équipements` (direct alias `4/F4`) provided supplied-equipment browsing,
|
||||||
search, detail, and custom-equipment creation and selection. Supplied definitions are generated
|
search, detail, and custom-equipment creation and selection. Supplied definitions are generated
|
||||||
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
|
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
|
||||||
SQLite and synchronize separately through definitions V1.
|
SQLite and synchronize separately through definitions V1.
|
||||||
|
|
@ -635,3 +637,6 @@ passed. See [Training knowledge system V1](domain/knowledge_system.md).
|
||||||
The normal Sessions hub has current/manual/completed paths only; the retained
|
The normal Sessions hub has current/manual/completed paths only; the retained
|
||||||
SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher
|
SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher
|
||||||
dispatch the one registered list-search action on supported lists.
|
dispatch the one registered list-search action on supported lists.
|
||||||
|
Equipment Detail lists V2 verified exercise options and confidence within the
|
||||||
|
72x20 layout. The generated core also exposes bounded reverse and BODY ZONE
|
||||||
|
queries without changing Body Focus geometry or session generation.
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,106 @@ def main():
|
||||||
assert canonical_conflict.returncode != 0
|
assert canonical_conflict.returncode != 0
|
||||||
assert 'conflit exercice association' in canonical_conflict.stdout
|
assert 'conflit exercice association' in canonical_conflict.stdout
|
||||||
|
|
||||||
|
# MACHINE_EXERCISE_MODEL_V1: a stale pre-split companion is accepted
|
||||||
|
# only for the closed set of completed stable entries and only when the
|
||||||
|
# current V3 snapshot proves the exact machine-specific target.
|
||||||
|
split_db = directory / 'machine-splits.db'
|
||||||
|
db = sqlite3.connect(split_db)
|
||||||
|
db.executescript("""
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
|
||||||
|
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
|
||||||
|
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE,
|
||||||
|
session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
|
||||||
|
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
|
label_name TEXT NOT NULL, equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL);
|
||||||
|
CREATE TABLE draft_session_exercises(entry_id TEXT PRIMARY KEY, exercise_id TEXT, equipment_id TEXT);
|
||||||
|
PRAGMA user_version=13;
|
||||||
|
""")
|
||||||
|
plate_old = 'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde'
|
||||||
|
plate_new = 'ex_0e26c06f-a458-40a4-be20-4ed219ede30d'
|
||||||
|
walk_old = 'ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d'
|
||||||
|
treadmill_new = 'ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2'
|
||||||
|
seated_leg = 'ex_617007f9-7420-4408-91b9-8ffb77900f13'
|
||||||
|
rear_delt = 'ex_4cd2433e-80b1-478a-b8df-487bf6014ce6'
|
||||||
|
ids = [plate_new, walk_old, treadmill_new, seated_leg, rear_delt, 'ex_unrelated']
|
||||||
|
db.executemany('INSERT INTO exercises VALUES(?,?)', enumerate(ids, 1))
|
||||||
|
sessions = [
|
||||||
|
('se_c0d07454-b58b-403e-8b79-744619815fc5', '2026-09-07T19:27:35+02:00'),
|
||||||
|
('se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a', '2026-09-08T11:25:30+02:00'),
|
||||||
|
]
|
||||||
|
db.executemany('INSERT INTO sessions(id,session_id,started_at) VALUES(?,?,?)',
|
||||||
|
[(i, *row) for i, row in enumerate(sessions, 1)])
|
||||||
|
row_by_id = {value: index for index, value in enumerate(ids, 1)}
|
||||||
|
occurrences = [
|
||||||
|
('sxe_draft_legacy_7', 1, plate_new, 0, 'plate_loaded_leg_press'),
|
||||||
|
('sxe_093c1331-beaa-4b69-91b3-240292709be6', 1, treadmill_new, 1, 'treadmill'),
|
||||||
|
('sxe_draft_legacy_1', 1, walk_old, 2, None),
|
||||||
|
('sxe_draft_legacy_3', 1, seated_leg, 3, 'seated_leg_curl'),
|
||||||
|
('sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7', 2, rear_delt, 0, None),
|
||||||
|
]
|
||||||
|
db.executemany(
|
||||||
|
'INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,position,equipment_id) VALUES(?,?,?,?,?)',
|
||||||
|
[(entry, session, row_by_id[exercise], position, equipment)
|
||||||
|
for entry, session, exercise, position, equipment in occurrences])
|
||||||
|
db.execute("INSERT INTO draft_session_exercises VALUES('sxe_draft_current',?,?)",
|
||||||
|
(walk_old, 'treadmill'))
|
||||||
|
db.commit(); db.close()
|
||||||
|
|
||||||
|
split_artifact = directory / 'stale-machine-associations.json'
|
||||||
|
split_payload = {
|
||||||
|
'format': 'trainlog-equipment-associations', 'version': 2,
|
||||||
|
'generated_at': '2026-09-12T08:05:57+02:00',
|
||||||
|
'associations': [
|
||||||
|
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_7',
|
||||||
|
'exercise_id': plate_old, 'state': 'set', 'equipment_id': 'plate_loaded_leg_press'},
|
||||||
|
{'session_id': sessions[0][0], 'entry_id': 'sxe_093c1331-beaa-4b69-91b3-240292709be6',
|
||||||
|
'exercise_id': walk_old, 'state': 'set', 'equipment_id': 'treadmill'},
|
||||||
|
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_1',
|
||||||
|
'exercise_id': walk_old, 'state': 'cleared'},
|
||||||
|
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_3',
|
||||||
|
'exercise_id': seated_leg, 'state': 'set', 'equipment_id': 'seated_leg_curl'},
|
||||||
|
{'session_id': sessions[1][0], 'entry_id': 'sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7',
|
||||||
|
'exercise_id': rear_delt, 'state': 'cleared'},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
split_artifact.write_text(json.dumps(split_payload))
|
||||||
|
proof = directory / 'current-machine-v3.json'
|
||||||
|
proof.write_text(json.dumps({
|
||||||
|
'format': 'trainlog-mobile-export', 'version': 3,
|
||||||
|
'generated_at': '2026-09-12T09:26:00+02:00', 'exercises': [],
|
||||||
|
'body_observations': [], 'sessions': [
|
||||||
|
{'session_id': session_id, 'exercises': [
|
||||||
|
{'entry_id': entry, 'exercise_id': exercise}
|
||||||
|
for entry, session, exercise, _position, _equipment in occurrences
|
||||||
|
if session == session_index
|
||||||
|
]}
|
||||||
|
for session_index, (session_id, _started) in enumerate(sessions, 1)
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
for _ in range(2):
|
||||||
|
accepted = subprocess.run(
|
||||||
|
[sys.executable, ROOT / 'tools/import_equipment_associations.py', split_artifact,
|
||||||
|
'--database', split_db, '--mobile-export', proof],
|
||||||
|
text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
assert accepted.returncode == 0, accepted.stdout + accepted.stderr
|
||||||
|
with sqlite3.connect(split_db) as db:
|
||||||
|
assert db.execute('SELECT exercise_id,equipment_id FROM draft_session_exercises').fetchone() == (walk_old, 'treadmill')
|
||||||
|
assert db.execute('SELECT exercise_id FROM exercises WHERE exercise_id=?', (seated_leg,)).fetchone() == (seated_leg,)
|
||||||
|
assert db.execute('SELECT equipment_id FROM session_exercises WHERE entry_id=?',
|
||||||
|
('sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7',)).fetchone() == (None,)
|
||||||
|
|
||||||
|
conflict_payload = json.loads(split_artifact.read_text())
|
||||||
|
conflict_payload['associations'][0]['exercise_id'] = 'ex_unrelated'
|
||||||
|
split_artifact.write_text(json.dumps(conflict_payload))
|
||||||
|
true_conflict = subprocess.run(
|
||||||
|
[sys.executable, ROOT / 'tools/import_equipment_associations.py', split_artifact,
|
||||||
|
'--database', split_db, '--mobile-export', proof],
|
||||||
|
text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
assert true_conflict.returncode != 0
|
||||||
|
assert 'conflit exercice association' in true_conflict.stdout
|
||||||
|
|
||||||
# The importer must validate the entire artifact before applying its
|
# The importer must validate the entire artifact before applying its
|
||||||
# first otherwise-valid association. v2 cannot transport custom IDs.
|
# first otherwise-valid association. v2 cannot transport custom IDs.
|
||||||
prevalidation_artifact = directory / 'prevalidation-rejected.json'
|
prevalidation_artifact = directory / 'prevalidation-rejected.json'
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,7 @@ def main():
|
||||||
with sqlite3.connect(names_db) as connection:
|
with sqlite3.connect(names_db) as connection:
|
||||||
assert connection.execute(
|
assert connection.execute(
|
||||||
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
|
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
|
||||||
).fetchone()[0] == "Flexion de genou assise"
|
).fetchone()[0] == "Seated Leg Curl"
|
||||||
assert connection.execute(
|
assert connection.execute(
|
||||||
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,)
|
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,)
|
||||||
).fetchone()[0] == "Seated Leg"
|
).fetchone()[0] == "Seated Leg"
|
||||||
|
|
@ -573,7 +573,7 @@ def main():
|
||||||
for artifact in (names_catalog, names_mobile):
|
for artifact in (names_catalog, names_mobile):
|
||||||
exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"]
|
exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"]
|
||||||
by_id = {item["exercise_id"]: item["name"] for item in 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_CURL_ID] == "Seated Leg Curl"
|
||||||
assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg"
|
assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg"
|
||||||
assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl"
|
assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class TrainingKnowledgeValidationTest(unittest.TestCase):
|
||||||
"body-zones-v1.json", "equipment-v1.json", "science-references-v1.json", "muscles-v1.json",
|
"body-zones-v1.json", "equipment-v1.json", "science-references-v1.json", "muscles-v1.json",
|
||||||
"joint-actions-v1.json", "movement-patterns-v1.json",
|
"joint-actions-v1.json", "movement-patterns-v1.json",
|
||||||
"exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
"exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
||||||
|
"equipment-exercise-relations-v2.json",
|
||||||
"training-knowledge-audit-v1.json",
|
"training-knowledge-audit-v1.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -128,6 +129,20 @@ class TrainingKnowledgeValidationTest(unittest.TestCase):
|
||||||
self.mutate("equipment-knowledge-v1.json", mutation)
|
self.mutate("equipment-knowledge-v1.json", mutation)
|
||||||
self.assert_invalid()
|
self.assert_invalid()
|
||||||
|
|
||||||
|
def test_equipment_v2_rejects_phantoms_duplicates_and_unresolved_seated_leg(self):
|
||||||
|
original = (self.catalog / "equipment-exercise-relations-v2.json").read_bytes()
|
||||||
|
mutations = [
|
||||||
|
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("exercise_id", "ex_00000000-0000-4000-8000-000000000000"),
|
||||||
|
lambda root: root["equipment_relations"][0]["exercise_options"].append(copy.deepcopy(root["equipment_relations"][0]["exercise_options"][0])),
|
||||||
|
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("confidence", "likely"),
|
||||||
|
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("source_refs", ["missing_ref"]),
|
||||||
|
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("exercise_id", "ex_617007f9-7420-4408-91b9-8ffb77900f13"),
|
||||||
|
]
|
||||||
|
for mutation in mutations:
|
||||||
|
(self.catalog / "equipment-exercise-relations-v2.json").write_bytes(original)
|
||||||
|
self.mutate("equipment-exercise-relations-v2.json", mutation)
|
||||||
|
self.assert_invalid()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||||
raise ValueError("schema desktop v8 à v12 requis")
|
raise ValueError("schema desktop v8 à v13 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ def main():
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||||
raise ValueError("schema desktop v8 à v12 requis")
|
raise ValueError("schema desktop v8 à v13 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
"FROM custom_equipment ORDER BY equipment_id")]
|
"FROM custom_equipment ORDER BY equipment_id")]
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = sqlite3.connect(args.database)
|
con = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
|
||||||
raise ValueError("schema desktop v12 requis")
|
raise ValueError("schema desktop v12/v13 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ def main():
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
|
||||||
raise ValueError("schema desktop v11/v12 requis")
|
raise ValueError("schema desktop v11/v12/v13 requis")
|
||||||
exercises = []
|
exercises = []
|
||||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
||||||
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:
|
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in (8, 9, 10, 11, 12):
|
if version not in (8, 9, 10, 11, 12, 13):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
|
if schema_version not in (11, 12, 13) 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/v13 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
canonical_names = load_exercise_names()
|
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(
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ def main() -> None:
|
||||||
patterns = load(root / "movement-patterns-v1.json", "movement_patterns")
|
patterns = load(root / "movement-patterns-v1.json", "movement_patterns")
|
||||||
exercises = load(root / "exercise-knowledge-v1.json", "exercises")
|
exercises = load(root / "exercise-knowledge-v1.json", "exercises")
|
||||||
equipment = load(root / "equipment-knowledge-v1.json", "equipment")
|
equipment = load(root / "equipment-knowledge-v1.json", "equipment")
|
||||||
|
relation_groups = load(root / "equipment-exercise-relations-v2.json", "equipment_relations")
|
||||||
lines = ['#include "trainlog/training_knowledge.h"',
|
lines = ['#include "trainlog/training_knowledge.h"',
|
||||||
'#include "trainlog/body_zone_catalog.h"', "#include <string.h>", ""]
|
'#include "trainlog/body_zone_catalog.h"', "#include <string.h>", ""]
|
||||||
lines.append("static const TrainlogKnowledgeReference references[] = {")
|
lines.append("static const TrainlogKnowledgeReference references[] = {")
|
||||||
|
|
@ -114,6 +115,14 @@ def main() -> None:
|
||||||
fields.append("true" if row["requires_actual_exercise"] else "false")
|
fields.append("true" if row["requires_actual_exercise"] else "false")
|
||||||
lines.append(" {%s}," % ",".join(fields))
|
lines.append(" {%s}," % ",".join(fields))
|
||||||
lines.append("};")
|
lines.append("};")
|
||||||
|
relations = [(row["equipment_id"], option) for row in relation_groups
|
||||||
|
for option in row["exercise_options"]]
|
||||||
|
lines.append("static const TrainlogEquipmentExerciseRelation equipment_exercise_relations[] = {")
|
||||||
|
for equipment_id, option in relations:
|
||||||
|
lines.append(" {%s}," % ",".join(c(value) for value in (
|
||||||
|
equipment_id, option["exercise_id"], option["relation_type"],
|
||||||
|
option["configuration_label"], option["confidence"], option["source_refs"])))
|
||||||
|
lines.append("};")
|
||||||
capabilities = [(row["equipment_id"], cap) for row in equipment for cap in row["capabilities"]]
|
capabilities = [(row["equipment_id"], cap) for row in equipment for cap in row["capabilities"]]
|
||||||
lines.append("static const TrainlogKnowledgeInterpretation capability_interpretations[] = {")
|
lines.append("static const TrainlogKnowledgeInterpretation capability_interpretations[] = {")
|
||||||
for _, cap in capabilities:
|
for _, cap in capabilities:
|
||||||
|
|
@ -158,6 +167,40 @@ size_t trainlog_equipment_capability_count(void) { return sizeof(capabilities)/s
|
||||||
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index) {
|
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index) {
|
||||||
return index < trainlog_equipment_capability_count() ? &capabilities[index] : NULL;
|
return index < trainlog_equipment_capability_count() ? &capabilities[index] : NULL;
|
||||||
}
|
}
|
||||||
|
size_t trainlog_equipment_exercise_relation_count(void) {
|
||||||
|
return sizeof(equipment_exercise_relations)/sizeof(equipment_exercise_relations[0]);
|
||||||
|
}
|
||||||
|
const TrainlogEquipmentExerciseRelation *trainlog_equipment_exercise_relation_at(size_t index) {
|
||||||
|
return index < trainlog_equipment_exercise_relation_count() ? &equipment_exercise_relations[index] : NULL;
|
||||||
|
}
|
||||||
|
TrainlogStatus trainlog_knowledge_list_exercises_for_equipment(const char *equipment_id,
|
||||||
|
const TrainlogExerciseKnowledge **output, size_t capacity, size_t *output_count) {
|
||||||
|
size_t i, count=0;
|
||||||
|
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
*output_count=0;
|
||||||
|
if (trainlog_equipment_knowledge_lookup(equipment_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
for (i=0; i<trainlog_equipment_exercise_relation_count(); ++i) {
|
||||||
|
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[i];
|
||||||
|
if (strcmp(r->equipment_id,equipment_id)!=0) continue;
|
||||||
|
if (count<capacity) output[count]=trainlog_exercise_knowledge_lookup(r->exercise_id);
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
TrainlogStatus trainlog_knowledge_list_equipment_for_exercise(const char *exercise_id,
|
||||||
|
const TrainlogEquipmentKnowledge **output, size_t capacity, size_t *output_count) {
|
||||||
|
size_t i, count=0;
|
||||||
|
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
*output_count=0;
|
||||||
|
if (trainlog_exercise_knowledge_lookup(exercise_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
for (i=0; i<trainlog_equipment_exercise_relation_count(); ++i) {
|
||||||
|
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[i];
|
||||||
|
if (strcmp(r->exercise_id,exercise_id)!=0) continue;
|
||||||
|
if (count<capacity) output[count]=trainlog_equipment_knowledge_lookup(r->equipment_id);
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
const TrainlogKnowledgeInterpretation *trainlog_exercise_knowledge_conditional(const char *exercise_id) {
|
const TrainlogKnowledgeInterpretation *trainlog_exercise_knowledge_conditional(const char *exercise_id) {
|
||||||
const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup(exercise_id);
|
const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup(exercise_id);
|
||||||
return record == NULL ? NULL : record->conditional_interpretation;
|
return record == NULL ? NULL : record->conditional_interpretation;
|
||||||
|
|
@ -175,6 +218,29 @@ static bool zone_matches(const TrainlogKnowledgeInterpretation *value, const cha
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
TrainlogStatus trainlog_knowledge_list_equipment_for_body_zone(const char *zone_id, bool descendants,
|
||||||
|
const TrainlogEquipmentKnowledge **output, size_t capacity, size_t *output_count) {
|
||||||
|
size_t i, count=0;
|
||||||
|
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
*output_count=0;
|
||||||
|
if (trainlog_body_zone_catalog_lookup(zone_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
/* CONTRACT: zone -> resolved exercise anatomy -> physical relation. Machine
|
||||||
|
* labels never participate in this derivation. */
|
||||||
|
for (i=0; i<trainlog_equipment_knowledge_count(); ++i) {
|
||||||
|
const TrainlogEquipmentKnowledge *eq=&equipment[i]; size_t j; bool matched=false;
|
||||||
|
for (j=0; j<trainlog_equipment_exercise_relation_count(); ++j) {
|
||||||
|
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[j];
|
||||||
|
const TrainlogExerciseKnowledge *e;
|
||||||
|
if (strcmp(r->equipment_id,eq->equipment_id)!=0) continue;
|
||||||
|
e=trainlog_exercise_knowledge_lookup(r->exercise_id);
|
||||||
|
if (e!=NULL && e->interpretation!=NULL && zone_matches(e->interpretation,zone_id,descendants)) { matched=true; break; }
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
if (count<capacity) output[count]=eq;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
TrainlogStatus trainlog_exercise_knowledge_query(const TrainlogKnowledgeQuery *query,
|
TrainlogStatus trainlog_exercise_knowledge_query(const TrainlogKnowledgeQuery *query,
|
||||||
const TrainlogExerciseKnowledge **output, size_t capacity, size_t *output_count) {
|
const TrainlogExerciseKnowledge **output, size_t capacity, size_t *output_count) {
|
||||||
size_t i, count=0; const TrainlogKnowledgeQuery empty={0};
|
size_t i, count=0; const TrainlogKnowledgeQuery empty={0};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,31 @@ from pathlib import Path
|
||||||
FORMAT = "trainlog-equipment-associations"
|
FORMAT = "trainlog-equipment-associations"
|
||||||
VERSION = 2
|
VERSION = 2
|
||||||
|
|
||||||
|
# CONTRACT: MACHINE_EXERCISE_MODEL_V1 split only these completed occurrences.
|
||||||
|
# The legacy exercise IDs are deliberately not aliases: other occurrences of
|
||||||
|
# the same source exercise remain valid unsplit history.
|
||||||
|
MACHINE_EXERCISE_SPLITS = {
|
||||||
|
("se_c0d07454-b58b-403e-8b79-744619815fc5", "sxe_draft_legacy_7"):
|
||||||
|
("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde",
|
||||||
|
"ex_0e26c06f-a458-40a4-be20-4ed219ede30d"),
|
||||||
|
("se_c0d07454-b58b-403e-8b79-744619815fc5",
|
||||||
|
"sxe_093c1331-beaa-4b69-91b3-240292709be6"):
|
||||||
|
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
|
||||||
|
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
|
||||||
|
("se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a",
|
||||||
|
"sxe_f25142c8-455e-4346-9bfc-31d0989e275d"):
|
||||||
|
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
|
||||||
|
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
|
||||||
|
("se_8a7ac0cb-9e67-42b6-bac9-179bbedf0024",
|
||||||
|
"sxe_f2242691-ba6c-48a0-b938-a1f744375d75"):
|
||||||
|
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
|
||||||
|
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
|
||||||
|
("se_8e6baa18-53ff-486b-b011-02a15291789e",
|
||||||
|
"sxe_9f8882f4-069e-49d5-8216-19d16b467e4a"):
|
||||||
|
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
|
||||||
|
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fail(message):
|
def fail(message):
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|
@ -55,11 +80,11 @@ def validate_associations(associations, known):
|
||||||
|
|
||||||
|
|
||||||
def load_mobile_occurrences(path):
|
def load_mobile_occurrences(path):
|
||||||
"""Return the source V2 identity claimed for every stable occurrence."""
|
"""Return the current V2/V3 identity claimed for every stable occurrence."""
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if (
|
if (
|
||||||
payload.get("format") != "trainlog-mobile-export"
|
payload.get("format") != "trainlog-mobile-export"
|
||||||
or payload.get("version") != 2
|
or payload.get("version") not in (2, 3)
|
||||||
or not isinstance(payload.get("sessions"), list)
|
or not isinstance(payload.get("sessions"), list)
|
||||||
):
|
):
|
||||||
fail("snapshot mobile V2 de preuve invalide")
|
fail("snapshot mobile V2 de preuve invalide")
|
||||||
|
|
@ -91,6 +116,18 @@ def load_mobile_occurrences(path):
|
||||||
return occurrences
|
return occurrences
|
||||||
|
|
||||||
|
|
||||||
|
def approved_machine_split(session_id, entry_id, incoming_id, local_id,
|
||||||
|
mobile_occurrences):
|
||||||
|
"""Corroborate one frozen completed-history split with the current snapshot."""
|
||||||
|
expected = MACHINE_EXERCISE_SPLITS.get((session_id, entry_id))
|
||||||
|
if expected != (incoming_id, local_id):
|
||||||
|
return False
|
||||||
|
# INVARIANT: the stale companion alone is never authority. The mobile
|
||||||
|
# snapshot selected and imported in this same sync must already carry the
|
||||||
|
# exact v13 target identity for this stable completed occurrence.
|
||||||
|
return mobile_occurrences.get((session_id, entry_id)) == local_id
|
||||||
|
|
||||||
|
|
||||||
def canonical_exercise_id(connection, exercise_id):
|
def canonical_exercise_id(connection, exercise_id):
|
||||||
"""Resolve one flattened durable alias without inventing identity."""
|
"""Resolve one flattened durable alias without inventing identity."""
|
||||||
aliases_available = connection.execute(
|
aliases_available = connection.execute(
|
||||||
|
|
@ -123,7 +160,7 @@ def main():
|
||||||
"--mobile-export",
|
"--mobile-export",
|
||||||
type=Path,
|
type=Path,
|
||||||
help=(
|
help=(
|
||||||
"snapshot mobile V2 importé juste avant ce companion; requis "
|
"snapshot mobile V2/V3 importé juste avant ce companion; requis "
|
||||||
"pour prouver un exercise_id réconcilié"
|
"pour prouver un exercise_id réconcilié"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -135,8 +172,8 @@ def main():
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||||
fail("schema desktop v8 à v12 requis")
|
fail("schema desktop v8 à v13 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
@ -170,12 +207,15 @@ def main():
|
||||||
"SELECT 1 FROM exercises WHERE exercise_id=?;",
|
"SELECT 1 FROM exercises WHERE exercise_id=?;",
|
||||||
(exercise_id,),
|
(exercise_id,),
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
if proof != exercise_id or incoming_still_exists:
|
alias_reconciliation = proof == exercise_id and not incoming_still_exists
|
||||||
|
split_reconciliation = approved_machine_split(
|
||||||
|
session_id, entry_id, incoming_canonical,
|
||||||
|
stored_canonical, mobile_occurrences)
|
||||||
|
if not alias_reconciliation and not split_reconciliation:
|
||||||
fail(f"conflit exercice association: {session_id}/{entry_id}")
|
fail(f"conflit exercice association: {session_id}/{entry_id}")
|
||||||
# CONTRACT: this fallback is only for schemas/runs without a
|
# CONTRACT: the first fallback is only for schemas/runs without
|
||||||
# persistent alias. import_mobile_export may have removed a
|
# a persistent alias. The second is the closed v13 split table
|
||||||
# profile-compatible duplicate; the selected V2 snapshot then
|
# above; no source ID becomes a global one-to-many alias.
|
||||||
# proves the stale source identity for this stable occurrence.
|
|
||||||
# INVARIANT: identity reconciliation never changes session_id,
|
# INVARIANT: identity reconciliation never changes session_id,
|
||||||
# entry_id, equipment_id, or any persisted occurrence. A distinct
|
# entry_id, equipment_id, or any persisted occurrence. A distinct
|
||||||
# live canonical exercise therefore remains a hard conflict.
|
# live canonical exercise therefore remains a hard conflict.
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ def main():
|
||||||
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
|
||||||
fail("schema desktop v8 à v12 requis")
|
fail("schema desktop v8 à v13 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
for definition in definitions:
|
for definition in definitions:
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ def main():
|
||||||
con = sqlite3.connect(args.database)
|
con = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
con.execute("PRAGMA foreign_keys=ON")
|
con.execute("PRAGMA foreign_keys=ON")
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
|
||||||
fail("schema desktop v12 requis")
|
fail("schema desktop v12/v13 requis")
|
||||||
con.execute("BEGIN IMMEDIATE")
|
con.execute("BEGIN IMMEDIATE")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
|
|
|
||||||
|
|
@ -204,8 +204,8 @@ def main():
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
updated = skipped = kept_local = 0
|
updated = skipped = kept_local = 0
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
|
||||||
raise ImportFailure("schema desktop v11/v12 requis")
|
raise ImportFailure("schema desktop v11/v12/v13 requis")
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
grouped = {}
|
grouped = {}
|
||||||
|
|
|
||||||
|
|
@ -820,9 +820,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in (5, 6, 7, 8, 9, 10, 11, 12):
|
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"base desktop schema v5 à v12 attendue, version trouvée: {version}"
|
f"base desktop schema v5 à v13 attendue, version trouvée: {version}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
183
tools/migrate_machine_exercises_v1.py
Normal file
183
tools/migrate_machine_exercises_v1.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply MACHINE_EXERCISE_MODEL_V1 to an explicit non-live SQLite database.
|
||||||
|
|
||||||
|
The executor is intentionally manifest-driven and refuses the canonical live
|
||||||
|
desktop path. Context splits match entry ID, canonical source exercise ID and
|
||||||
|
the exact legacy equipment ID before any write begins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from exercise_names import normalize_catalog_name
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
|
||||||
|
LIVE_DB = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
|
||||||
|
COLUMNS = (
|
||||||
|
("load_semantics", "TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'))"),
|
||||||
|
("machine_variant", "TEXT"),
|
||||||
|
("machine_provenance", "TEXT"),
|
||||||
|
("scientific_profile_id", "TEXT"),
|
||||||
|
("science_state", "TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'))"),
|
||||||
|
("legacy_equipment_id", "TEXT"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message):
|
||||||
|
raise RuntimeError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def row_id(connection, exercise_id):
|
||||||
|
row = connection.execute("SELECT id FROM exercises WHERE exercise_id=?", (exercise_id,)).fetchone()
|
||||||
|
return None if row is None else row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def split_facts(connection, entry_ids):
|
||||||
|
placeholders = ",".join("?" for _ in entry_ids)
|
||||||
|
return connection.execute(f"""
|
||||||
|
SELECT se.entry_id,e.exercise_id,se.equipment_id,
|
||||||
|
(SELECT count(*) FROM performed_sets p WHERE p.session_exercise_row_id=se.id),
|
||||||
|
(SELECT count(*) FROM max_results m WHERE m.session_exercise_row_id=se.id),
|
||||||
|
(SELECT count(*) FROM continuous_activity c WHERE c.session_exercise_row_id=se.id)
|
||||||
|
FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id
|
||||||
|
WHERE se.entry_id IN ({placeholders}) ORDER BY se.entry_id
|
||||||
|
""", entry_ids).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_preconditions(connection, manifest):
|
||||||
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
if version not in (12, 13):
|
||||||
|
fail(f"schema v12 or v13 required, found v{version}")
|
||||||
|
split_entries = []
|
||||||
|
for rename in manifest["renames"]:
|
||||||
|
row = connection.execute("SELECT name FROM exercises WHERE exercise_id=?", (rename["exercise_id"],)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
fail(f"rename source missing: {rename['exercise_id']}")
|
||||||
|
if version == 12 and row[0] != rename["old_display_name"]:
|
||||||
|
fail(f"rename source name mismatch: {rename['exercise_id']}")
|
||||||
|
if version == 13 and row[0] != rename["new_display_name"]:
|
||||||
|
fail(f"migrated rename mismatch: {rename['exercise_id']}")
|
||||||
|
for split in manifest["context_splits"]:
|
||||||
|
split_entries.extend(split["entry_ids"])
|
||||||
|
facts = split_facts(connection, split["entry_ids"])
|
||||||
|
expected_exercise = split["target_exercise_id"] if version == 13 else split["source_exercise_id"]
|
||||||
|
if len(facts) != split["expected_occurrence_count"] or {row[0] for row in facts} != set(split["entry_ids"]):
|
||||||
|
fail(f"split occurrence mismatch: {split['target_exercise_id']}")
|
||||||
|
if any(row[1] != expected_exercise or row[2] != split["source_equipment_id"] for row in facts):
|
||||||
|
fail(f"split identity/context mismatch: {split['target_exercise_id']}")
|
||||||
|
if sum(row[3] for row in facts) != split["expected_performed_set_count"] or sum(row[4] for row in facts) != split["expected_max_count"] or sum(row[5] for row in facts) != split["expected_continuous_count"]:
|
||||||
|
fail(f"split child count mismatch: {split['target_exercise_id']}")
|
||||||
|
if len(split_entries) != len(set(split_entries)):
|
||||||
|
fail("one entry appears in multiple split targets")
|
||||||
|
unresolved = set()
|
||||||
|
for item in manifest["unresolved_contexts"]:
|
||||||
|
unresolved.update([item["entry_id"]] if "entry_id" in item else item.get("historical_entry_ids", []))
|
||||||
|
if unresolved & set(split_entries):
|
||||||
|
fail("unresolved entry included in automatic split")
|
||||||
|
|
||||||
|
|
||||||
|
def profile_interpretations():
|
||||||
|
profiles = json.loads((ROOT / "catalog/scientific-profiles-v1.json").read_text(encoding="utf-8"))["profiles"]
|
||||||
|
exercises = {row["exercise_id"]: row for row in json.loads((ROOT / "catalog/exercise-knowledge-v1.json").read_text(encoding="utf-8"))["exercises"]}
|
||||||
|
equipment = {row["equipment_id"]: row for row in json.loads((ROOT / "catalog/equipment-knowledge-v1.json").read_text(encoding="utf-8"))["equipment"]}
|
||||||
|
result = {}
|
||||||
|
for profile in profiles:
|
||||||
|
source = profile["knowledge_source"]
|
||||||
|
if source["kind"] == "exercise":
|
||||||
|
interpretation = exercises[source["exercise_id"]].get("interpretation")
|
||||||
|
else:
|
||||||
|
capability = next(cap for cap in equipment[source["equipment_id"]]["capabilities"] if cap["display_name"] == source["capability"])
|
||||||
|
interpretation = capability.get("interpretation")
|
||||||
|
if interpretation is None:
|
||||||
|
fail(f"resolved profile has no interpretation: {profile['scientific_profile_id']}")
|
||||||
|
result[profile["scientific_profile_id"]] = interpretation
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def seed_zones(connection, exercise_id, interpretation):
|
||||||
|
target = row_id(connection, exercise_id)
|
||||||
|
if target is None:
|
||||||
|
fail(f"cannot seed zones for missing exercise: {exercise_id}")
|
||||||
|
existing = connection.execute("SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=? ORDER BY role,zone_id", (target,)).fetchall()
|
||||||
|
expected = [(interpretation["primary_zone_id"], "primary")] + [(zone, "secondary") for zone in sorted(interpretation["secondary_zone_ids"])]
|
||||||
|
if existing:
|
||||||
|
if sorted(existing) != sorted(expected):
|
||||||
|
fail(f"BODY ZONE conflict for {exercise_id}")
|
||||||
|
else:
|
||||||
|
connection.executemany("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,?)", [(target, zone, role) for zone, role in expected])
|
||||||
|
state = interpretation["primary_zone_id"] + "|" + ",".join(sorted(interpretation["secondary_zone_ids"]))
|
||||||
|
connection.execute("INSERT OR IGNORE INTO exercise_body_zone_sync(exercise_row_id,synced_state) VALUES(?,?)", (target, state))
|
||||||
|
|
||||||
|
|
||||||
|
def apply(connection, manifest):
|
||||||
|
validate_preconditions(connection, manifest)
|
||||||
|
if connection.execute("PRAGMA user_version").fetchone()[0] == 13:
|
||||||
|
return False
|
||||||
|
interpretations = profile_interpretations()
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
for name, definition in COLUMNS:
|
||||||
|
connection.execute(f"ALTER TABLE exercises ADD COLUMN {name} {definition}")
|
||||||
|
for rename in manifest["renames"]:
|
||||||
|
changed = connection.execute("UPDATE exercises SET name=?,normalized_name=?,load_semantics=?,machine_variant=NULL,scientific_profile_id=?,science_state='resolved',legacy_equipment_id=? WHERE exercise_id=? AND name=?",
|
||||||
|
(rename["new_display_name"], normalize_catalog_name(rename["new_display_name"]), rename["load_semantics"], rename["scientific_profile_id"], rename["legacy_equipment_id"], rename["exercise_id"], rename["old_display_name"])).rowcount
|
||||||
|
if changed != 1:
|
||||||
|
fail(f"rename update failed: {rename['exercise_id']}")
|
||||||
|
machine_rows = {row["exercise_id"]: row for row in json.loads((ROOT / "catalog/machine-exercises-v1.json").read_text(encoding="utf-8"))["exercises"]}
|
||||||
|
for rename in manifest["renames"]:
|
||||||
|
machine = machine_rows[rename["exercise_id"]]
|
||||||
|
connection.execute("UPDATE exercises SET machine_variant=? WHERE exercise_id=?", (machine["machine_variant"], rename["exercise_id"]))
|
||||||
|
for item in manifest["new_exercises"]:
|
||||||
|
machine = machine_rows[item["exercise_id"]]
|
||||||
|
connection.execute("""INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics,machine_variant,machine_provenance,scientific_profile_id,science_state,legacy_equipment_id)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,NULL,?,?,?)""", (item["exercise_id"], item["display_name"], normalize_catalog_name(item["display_name"]), item["tracking_mode"], item["recording_mode"], item["data_fields"], item["load_semantics"], machine["machine_variant"], item["scientific_profile_id"], machine["science_state"], item["legacy_equipment_id"]))
|
||||||
|
if item["scientific_profile_id"] is not None:
|
||||||
|
seed_zones(connection, item["exercise_id"], interpretations[item["scientific_profile_id"]])
|
||||||
|
for split in manifest["context_splits"]:
|
||||||
|
target = row_id(connection, split["target_exercise_id"])
|
||||||
|
source = row_id(connection, split["source_exercise_id"])
|
||||||
|
for entry in split["entry_ids"]:
|
||||||
|
changed = connection.execute("UPDATE session_exercises SET exercise_row_id=? WHERE entry_id=? AND exercise_row_id=? AND equipment_id=?", (target, entry, source, split["source_equipment_id"])).rowcount
|
||||||
|
if changed != 1:
|
||||||
|
fail(f"exact split update failed: {entry}")
|
||||||
|
seated = machine_rows["ex_617007f9-7420-4408-91b9-8ffb77900f13"]
|
||||||
|
connection.execute("UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id=?", (seated["exercise_id"],))
|
||||||
|
connection.execute("PRAGMA user_version=13")
|
||||||
|
validate_preconditions(connection, manifest)
|
||||||
|
if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
||||||
|
fail("foreign key check failed")
|
||||||
|
connection.commit()
|
||||||
|
except Exception:
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("database", type=Path)
|
||||||
|
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
||||||
|
parser.add_argument("--audit", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
database = args.database.resolve()
|
||||||
|
if database == LIVE_DB.resolve():
|
||||||
|
fail("refusing to migrate the canonical live desktop database")
|
||||||
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
connection = sqlite3.connect(database)
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
changed = apply(connection, manifest)
|
||||||
|
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
connection.close()
|
||||||
|
audit = {"format":"trainlog-machine-exercise-migration-audit","version":1,"database":str(database),"changed":changed,"schema_version":version,"integrity_check":integrity}
|
||||||
|
if args.audit:
|
||||||
|
args.audit.write_text(json.dumps(audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(audit, ensure_ascii=False, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
84
tools/validate_machine_exercises.py
Normal file
84
tools/validate_machine_exercises.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate MACHINE_EXERCISE_MODEL_V1 catalogs without inferring science."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from exercise_names import normalize_catalog_name
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||||
|
PROFILE_ID = re.compile(r"^sp_[a-z0-9_]+_v1$")
|
||||||
|
LOADS = {"none", "external", "assistance", "bodyweight", "cardio"}
|
||||||
|
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
return json.loads((ROOT / "catalog" / name).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
machine = load("machine-exercises-v1.json")
|
||||||
|
profiles = load("scientific-profiles-v1.json")
|
||||||
|
equipment = load("equipment-v1.json")
|
||||||
|
knowledge = load("exercise-knowledge-v1.json")
|
||||||
|
equipment_knowledge = load("equipment-knowledge-v1.json")
|
||||||
|
if set(machine) != {"format", "version", "exercises"} or machine["format"] != "trainlog-machine-exercises" or machine["version"] != 1:
|
||||||
|
raise ValueError("invalid machine exercise envelope")
|
||||||
|
if set(profiles) != {"format", "version", "profiles"} or profiles["format"] != "trainlog-scientific-profiles" or profiles["version"] != 1:
|
||||||
|
raise ValueError("invalid scientific profile envelope")
|
||||||
|
equipment_ids = {row["id"] for row in equipment["equipment"]}
|
||||||
|
exercise_knowledge = {row["exercise_id"] for row in knowledge["exercises"]}
|
||||||
|
capabilities = {(row["equipment_id"], cap["display_name"])
|
||||||
|
for row in equipment_knowledge["equipment"] for cap in row["capabilities"]}
|
||||||
|
profile_ids = set()
|
||||||
|
previous = ""
|
||||||
|
for row in profiles["profiles"]:
|
||||||
|
if set(row) != {"scientific_profile_id", "knowledge_source"}:
|
||||||
|
raise ValueError("invalid scientific profile record")
|
||||||
|
profile_id = row["scientific_profile_id"]
|
||||||
|
if not PROFILE_ID.fullmatch(profile_id) or profile_id <= previous or profile_id in profile_ids:
|
||||||
|
raise ValueError("scientific profiles must be unique and sorted")
|
||||||
|
previous = profile_id
|
||||||
|
profile_ids.add(profile_id)
|
||||||
|
source = row["knowledge_source"]
|
||||||
|
if source.get("kind") == "exercise":
|
||||||
|
if set(source) != {"kind", "exercise_id"} or source["exercise_id"] not in exercise_knowledge:
|
||||||
|
raise ValueError("unknown exercise knowledge source")
|
||||||
|
elif source.get("kind") == "equipment_capability":
|
||||||
|
if set(source) != {"kind", "equipment_id", "capability"} or (source["equipment_id"], source["capability"]) not in capabilities:
|
||||||
|
raise ValueError("unknown equipment capability source")
|
||||||
|
else:
|
||||||
|
raise ValueError("invalid profile source kind")
|
||||||
|
seen_ids = set()
|
||||||
|
seen_names = set()
|
||||||
|
previous = ""
|
||||||
|
for row in machine["exercises"]:
|
||||||
|
required = {"exercise_id", "display_name", "load_semantics", "machine_variant", "legacy_equipment_id", "scientific_profile_id", "science_state"}
|
||||||
|
if set(row) != required or not EXERCISE_ID.fullmatch(row["exercise_id"]):
|
||||||
|
raise ValueError("invalid machine exercise record")
|
||||||
|
if row["exercise_id"] <= previous or row["exercise_id"] in seen_ids:
|
||||||
|
raise ValueError("machine exercises must be unique and sorted")
|
||||||
|
previous = row["exercise_id"]
|
||||||
|
seen_ids.add(row["exercise_id"])
|
||||||
|
name = normalize_catalog_name(row["display_name"])
|
||||||
|
if name in seen_names:
|
||||||
|
raise ValueError("duplicate machine exercise name")
|
||||||
|
seen_names.add(name)
|
||||||
|
if row["load_semantics"] is not None and row["load_semantics"] not in LOADS:
|
||||||
|
raise ValueError("invalid load semantics")
|
||||||
|
if row["legacy_equipment_id"] is not None and row["legacy_equipment_id"] not in equipment_ids:
|
||||||
|
raise ValueError("unknown legacy equipment")
|
||||||
|
if row["scientific_profile_id"] is not None and row["scientific_profile_id"] not in profile_ids:
|
||||||
|
raise ValueError("unknown scientific profile")
|
||||||
|
resolved = row["science_state"] == "resolved"
|
||||||
|
if row["science_state"] not in {"resolved", "unresolved"} or resolved != (row["scientific_profile_id"] is not None):
|
||||||
|
raise ValueError("science state/profile mismatch")
|
||||||
|
seated_leg = next(row for row in machine["exercises"] if row["exercise_id"] == "ex_617007f9-7420-4408-91b9-8ffb77900f13")
|
||||||
|
if any(seated_leg[key] is not None for key in ("load_semantics", "legacy_equipment_id", "scientific_profile_id")):
|
||||||
|
raise ValueError("Seated Leg must remain unresolved")
|
||||||
|
print("MACHINE_EXERCISE_MODEL_V1 catalogs: OK")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -125,6 +125,11 @@ def validate(root: Path) -> None:
|
||||||
patterns_rows = envelope(load(root / "movement-patterns-v1.json"), "trainlog-movement-patterns-v1", "movement_patterns")
|
patterns_rows = envelope(load(root / "movement-patterns-v1.json"), "trainlog-movement-patterns-v1", "movement_patterns")
|
||||||
exercise_rows = envelope(load(root / "exercise-knowledge-v1.json"), "trainlog-exercise-knowledge-v1", "exercises")
|
exercise_rows = envelope(load(root / "exercise-knowledge-v1.json"), "trainlog-exercise-knowledge-v1", "exercises")
|
||||||
equipment_rows = envelope(load(root / "equipment-knowledge-v1.json"), "trainlog-equipment-knowledge-v1", "equipment")
|
equipment_rows = envelope(load(root / "equipment-knowledge-v1.json"), "trainlog-equipment-knowledge-v1", "equipment")
|
||||||
|
relation_root = load(root / "equipment-exercise-relations-v2.json")
|
||||||
|
require(set(relation_root) == {"format", "version", "equipment_relations"} and
|
||||||
|
relation_root["format"] == "trainlog-equipment-exercise-relations-v2" and
|
||||||
|
relation_root["version"] == 2 and isinstance(relation_root["equipment_relations"], list),
|
||||||
|
"invalid equipment relation V2 envelope")
|
||||||
refs = ids(references, "ref_id", "references")
|
refs = ids(references, "ref_id", "references")
|
||||||
muscles = ids(muscles_rows, "muscle_id", "muscles")
|
muscles = ids(muscles_rows, "muscle_id", "muscles")
|
||||||
actions = ids(actions_rows, "action_id", "joint_actions")
|
actions = ids(actions_rows, "action_id", "joint_actions")
|
||||||
|
|
@ -160,6 +165,34 @@ def validate(root: Path) -> None:
|
||||||
require(required_equipment <= equipment, "knowledge catalog misses supplied equipment")
|
require(required_equipment <= equipment, "knowledge catalog misses supplied equipment")
|
||||||
require(required_exercises <= exercises, "knowledge catalog misses observed exercises")
|
require(required_exercises <= exercises, "knowledge catalog misses observed exercises")
|
||||||
|
|
||||||
|
# CONTRACT: equipment relations contain identity/provenance only. Anatomy is
|
||||||
|
# exercise-owned and therefore cannot be duplicated or inferred here.
|
||||||
|
relation_rows = relation_root["equipment_relations"]
|
||||||
|
relation_equipment = ids(relation_rows, "equipment_id", "equipment relations")
|
||||||
|
require(relation_equipment <= equipment, "equipment relation references unknown equipment")
|
||||||
|
seen_relations = set()
|
||||||
|
for relation_row in relation_rows:
|
||||||
|
exact_object(relation_row, {"equipment_id", "exercise_options"}, "equipment relation")
|
||||||
|
options = relation_row["exercise_options"]
|
||||||
|
require(isinstance(options, list) and bool(options), "equipment relation options must be non-empty")
|
||||||
|
order = []
|
||||||
|
for option in options:
|
||||||
|
exact_object(option, {"exercise_id", "relation_type", "configuration_label",
|
||||||
|
"confidence", "source_refs"}, "equipment exercise option")
|
||||||
|
require(option["exercise_id"] in exercises, "equipment option references unknown exercise")
|
||||||
|
require(option["relation_type"] == "supported_exercise", "invalid equipment relation type")
|
||||||
|
nullable_text(option["configuration_label"], "equipment option.configuration_label")
|
||||||
|
confidence(option["confidence"], "equipment option.confidence")
|
||||||
|
refs_list(option["source_refs"], "equipment option.source_refs", refs)
|
||||||
|
pair = (relation_row["equipment_id"], option["exercise_id"])
|
||||||
|
require(pair not in seen_relations, "duplicate equipment/exercise relation")
|
||||||
|
seen_relations.add(pair)
|
||||||
|
order.append((option["exercise_id"], option["configuration_label"] or ""))
|
||||||
|
require(order == sorted(order), "equipment exercise options must be deterministically ordered")
|
||||||
|
seated_leg = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
|
||||||
|
require(all(exercise_id != seated_leg for _, exercise_id in seen_relations),
|
||||||
|
"Seated Leg is unresolved and must not inherit seated-leg-curl knowledge")
|
||||||
|
|
||||||
ref_required = {"ref_id", "title", "authors_or_organization", "year", "type", "url", "topics",
|
ref_required = {"ref_id", "title", "authors_or_organization", "year", "type", "url", "topics",
|
||||||
"notes", "limitations", "doi", "pmid", "accessed_on"}
|
"notes", "limitations", "doi", "pmid", "accessed_on"}
|
||||||
for row in references:
|
for row in references:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 12
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 13
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,17 @@ typedef struct TrainlogEquipmentCapability {
|
||||||
const TrainlogKnowledgeInterpretation *interpretation;
|
const TrainlogKnowledgeInterpretation *interpretation;
|
||||||
} TrainlogEquipmentCapability;
|
} TrainlogEquipmentCapability;
|
||||||
|
|
||||||
|
/** Physical-equipment compatibility with one already-known canonical exercise.
|
||||||
|
* INVARIANT: this record never owns muscles, movements, or BODY ZONES. */
|
||||||
|
typedef struct TrainlogEquipmentExerciseRelation {
|
||||||
|
const char *equipment_id;
|
||||||
|
const char *exercise_id;
|
||||||
|
const char *relation_type;
|
||||||
|
const char *configuration_label;
|
||||||
|
const char *confidence;
|
||||||
|
const char *source_refs;
|
||||||
|
} TrainlogEquipmentExerciseRelation;
|
||||||
|
|
||||||
typedef struct TrainlogKnowledgeQuery {
|
typedef struct TrainlogKnowledgeQuery {
|
||||||
const char *scientific_zone_id;
|
const char *scientific_zone_id;
|
||||||
bool include_zone_descendants;
|
bool include_zone_descendants;
|
||||||
|
|
@ -200,6 +211,18 @@ const TrainlogEquipmentKnowledge *trainlog_equipment_knowledge_at(size_t index);
|
||||||
const TrainlogEquipmentKnowledge *trainlog_equipment_knowledge_lookup(const char *equipment_id);
|
const TrainlogEquipmentKnowledge *trainlog_equipment_knowledge_lookup(const char *equipment_id);
|
||||||
size_t trainlog_equipment_capability_count(void);
|
size_t trainlog_equipment_capability_count(void);
|
||||||
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index);
|
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index);
|
||||||
|
size_t trainlog_equipment_exercise_relation_count(void);
|
||||||
|
const TrainlogEquipmentExerciseRelation *trainlog_equipment_exercise_relation_at(size_t index);
|
||||||
|
TrainlogStatus trainlog_knowledge_list_exercises_for_equipment(
|
||||||
|
const char *equipment_id, const TrainlogExerciseKnowledge **output,
|
||||||
|
size_t capacity, size_t *output_count);
|
||||||
|
TrainlogStatus trainlog_knowledge_list_equipment_for_exercise(
|
||||||
|
const char *exercise_id, const TrainlogEquipmentKnowledge **output,
|
||||||
|
size_t capacity, size_t *output_count);
|
||||||
|
TrainlogStatus trainlog_knowledge_list_equipment_for_body_zone(
|
||||||
|
const char *zone_id, bool include_descendants,
|
||||||
|
const TrainlogEquipmentKnowledge **output, size_t capacity,
|
||||||
|
size_t *output_count);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AND-combine optional resolved-knowledge filters in stable exercise-ID order.
|
* AND-combine optional resolved-knowledge filters in stable exercise-ID order.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ training_knowledge_generated = custom_target(
|
||||||
meson.project_source_root() / 'catalog/movement-patterns-v1.json',
|
meson.project_source_root() / 'catalog/movement-patterns-v1.json',
|
||||||
meson.project_source_root() / 'catalog/exercise-knowledge-v1.json',
|
meson.project_source_root() / 'catalog/exercise-knowledge-v1.json',
|
||||||
meson.project_source_root() / 'catalog/equipment-knowledge-v1.json',
|
meson.project_source_root() / 'catalog/equipment-knowledge-v1.json',
|
||||||
|
meson.project_source_root() / 'catalog/equipment-exercise-relations-v2.json',
|
||||||
meson.project_source_root() / 'catalog/training-knowledge-audit-v1.json',
|
meson.project_source_root() / 'catalog/training-knowledge-audit-v1.json',
|
||||||
],
|
],
|
||||||
output: 'training_knowledge_generated.c',
|
output: 'training_knowledge_generated.c',
|
||||||
|
|
|
||||||
|
|
@ -512,6 +512,60 @@ static const char *const MIGRATE_V11_TO_V12_SQL =
|
||||||
"ON exercise_aliases(canonical_exercise_id);"
|
"ON exercise_aliases(canonical_exercise_id);"
|
||||||
"PRAGMA user_version = 12;COMMIT;";
|
"PRAGMA user_version = 12;COMMIT;";
|
||||||
|
|
||||||
|
/* WHY: the selectable identity is now the concrete machine exercise; legacy
|
||||||
|
* equipment remains occurrence provenance for V3/old-peer compatibility.
|
||||||
|
* CONTRACT: v13 is additive, creates only the five frozen UUID identities and
|
||||||
|
* repoints only the approved entry/exercise/equipment triples. INVARIANT:
|
||||||
|
* session/entry IDs and every child fact remain untouched. */
|
||||||
|
static const char *const MIGRATE_V12_TO_V13_SQL_A =
|
||||||
|
"BEGIN IMMEDIATE;"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN load_semantics TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'));"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN machine_variant TEXT;"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN machine_provenance TEXT;"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT;"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'));"
|
||||||
|
"ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT;"
|
||||||
|
"UPDATE exercises SET name='Hip Abduction',normalized_name='hip abduction',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_hip_abduction_v1',science_state='resolved',legacy_equipment_id='hip_abduction' WHERE exercise_id='ex_7e7cf906-2214-4066-bcb7-c16382d83b3b' AND name='Abduction de hanche assise';"
|
||||||
|
"UPDATE exercises SET name='Leg Extension',normalized_name='leg extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_leg_extension_v1',science_state='resolved',legacy_equipment_id='leg_extension' WHERE exercise_id='ex_1872246a-39ae-44dc-b58d-f87e90ca49ab' AND name='Extension de genou assise';"
|
||||||
|
"UPDATE exercises SET name='Back Extension',normalized_name='back extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_back_extension_v1',science_state='resolved',legacy_equipment_id='back_extension' WHERE exercise_id='ex_474ec393-3efa-4aaa-8e08-1a0245ed7835' AND name='Extension du tronc';"
|
||||||
|
"UPDATE exercises SET name='Arm Curl',normalized_name='arm curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_arm_curl_v1',science_state='resolved',legacy_equipment_id='arm_curl' WHERE exercise_id='ex_ec619fc2-4685-4044-873c-86764bd4a0fe' AND name='Flexion de coude à la machine';"
|
||||||
|
"UPDATE exercises SET name='Prone Leg Curl',normalized_name='prone leg curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_prone_leg_curl_v1',science_state='resolved',legacy_equipment_id='prone_leg_curl' WHERE exercise_id='ex_d7398d9f-d928-4d2e-94e9-74e201da55c5' AND name='Flexion de genou couchée';"
|
||||||
|
"UPDATE exercises SET name='Rotary Torso',normalized_name='rotary torso',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_rotary_torso_v1',science_state='resolved',legacy_equipment_id='rotary_torso' WHERE exercise_id='ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0' AND name='Rotation du tronc à la machine';";
|
||||||
|
|
||||||
|
static const char *const MIGRATE_V12_TO_V13_SQL_B =
|
||||||
|
"UPDATE exercises SET name='Seated Row',normalized_name='seated row',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_seated_row_v1',science_state='resolved',legacy_equipment_id='seated_row' WHERE exercise_id='ex_33f79331-871c-4eed-babe-346e53a99070' AND name='Tirage horizontal assis';"
|
||||||
|
"UPDATE exercises SET name='Diverging Seated Row',normalized_name='diverging seated row',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_seated_row_v1',science_state='resolved',legacy_equipment_id='diverging_seated_row' WHERE exercise_id='ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de' AND name='Tirage horizontal divergent assis';"
|
||||||
|
"UPDATE exercises SET name='Diverging Lat Pulldown',normalized_name='diverging lat pulldown',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_vertical_pull_v1',science_state='resolved',legacy_equipment_id='diverging_lat_pulldown' WHERE exercise_id='ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6' AND name='Tirage vertical divergent';"
|
||||||
|
"UPDATE exercises SET name='Lat Pull',normalized_name='lat pull',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_vertical_pull_v1',science_state='resolved',legacy_equipment_id='lat_pull' WHERE exercise_id='ex_a72fa713-4b0e-431d-95e2-42d95beb77b1' AND name='Tirage vertical à la poulie';"
|
||||||
|
"UPDATE exercises SET name='Converging Shoulder Press',normalized_name='converging shoulder press',load_semantics='external',machine_variant='converging',scientific_profile_id='sp_shoulder_press_v1',science_state='resolved',legacy_equipment_id='converging_shoulder_press' WHERE exercise_id='ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b' AND name='Développé épaules convergent';"
|
||||||
|
"UPDATE exercises SET name='Seated Leg Curl',normalized_name='seated leg curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_seated_leg_curl_v1',science_state='resolved',legacy_equipment_id='seated_leg_curl' WHERE exercise_id='ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163' AND name='Flexion de genou assise';"
|
||||||
|
"UPDATE exercises SET name='Leg Press',normalized_name='leg press',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_leg_press_v1',science_state='resolved',legacy_equipment_id='leg_press' WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' AND name IN ('Presse à cuisses','Leg press');"
|
||||||
|
"UPDATE exercises SET name='Rear Delt',normalized_name='rear delt',load_semantics='external',machine_variant='rear_delt',scientific_profile_id='sp_rear_delt_v1',science_state='resolved',legacy_equipment_id='rear_delt_pec_fly' WHERE exercise_id='ex_4cd2433e-80b1-478a-b8df-73fc6ef80962' AND name='Écarté inversé à la machine';"
|
||||||
|
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics,machine_variant,scientific_profile_id,science_state,legacy_equipment_id) VALUES"
|
||||||
|
"('ex_0e26c06f-a458-40a4-be20-4ed219ede30d','Plate Loaded Leg Press','plate loaded leg press','reps','sets',0,'external','plate_loaded','sp_leg_press_v1','resolved','plate_loaded_leg_press'),"
|
||||||
|
"('ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2','Treadmill','treadmill','duration','continuous',3,'cardio','treadmill',NULL,'unresolved','treadmill'),"
|
||||||
|
"('ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee','Pec Fly','pec fly','reps','sets',0,'external','pec_fly','sp_pec_fly_v1','resolved','rear_delt_pec_fly'),"
|
||||||
|
"('ex_58b8dfbc-92b2-4783-a449-9947a42480b8','Chin Assist','chin assist','reps','sets',0,'assistance','assisted','sp_assisted_chin_v1','resolved','assisted_dip_chin_machine'),"
|
||||||
|
"('ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf','Dip Assist','dip assist','reps','sets',0,'assistance','assisted','sp_assisted_dip_v1','resolved','assisted_dip_chin_machine');";
|
||||||
|
|
||||||
|
static const char *const MIGRATE_V12_TO_V13_SQL_C =
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT n.id,z.zone_id,z.role FROM exercises n JOIN exercises o ON o.exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' JOIN exercise_body_zones z ON z.exercise_row_id=o.id WHERE n.exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','primary' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'shoulders','secondary' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'back','primary' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','secondary' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','primary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','secondary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
||||||
|
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'shoulders','secondary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
||||||
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'thighs|glutes' FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
|
||||||
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'chest|shoulders' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
|
||||||
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'back|arms' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
|
||||||
|
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'arms|chest,shoulders' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
|
||||||
|
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d') WHERE entry_id='sxe_draft_legacy_7' AND equipment_id='plate_loaded_leg_press' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde');"
|
||||||
|
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2') WHERE entry_id IN ('sxe_093c1331-beaa-4b69-91b3-240292709be6','sxe_f25142c8-455e-4346-9bfc-31d0989e275d','sxe_f2242691-ba6c-48a0-b938-a1f744375d75','sxe_9f8882f4-069e-49d5-8216-19d16b467e4a') AND equipment_id='treadmill' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d');"
|
||||||
|
"UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';"
|
||||||
|
"PRAGMA user_version=13;COMMIT;";
|
||||||
|
|
||||||
static const char *const MIGRATE_V1_TO_V3_SQL =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
"ALTER TABLE sessions "
|
"ALTER TABLE sessions "
|
||||||
|
|
@ -1028,7 +1082,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
} else if (version == 10) {
|
} else if (version == 10) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else if (version == 11) {
|
} else if (version == 11 || version == 12) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -1165,9 +1219,18 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
if (version < 11) status = migrate_v10_to_v11(database);
|
if (version < 11) status = migrate_v10_to_v11(database);
|
||||||
}
|
}
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK && version < 12) {
|
||||||
status = execute_sql(database, MIGRATE_V11_TO_V12_SQL);
|
status = execute_sql(database, MIGRATE_V11_TO_V12_SQL);
|
||||||
}
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK && version < 13) {
|
||||||
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_A);
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_B);
|
||||||
|
}
|
||||||
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
|
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_C);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
status !=
|
status !=
|
||||||
|
|
@ -1176,7 +1239,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v12" : "migrate database to schema v12",
|
version == 0 ? "create schema v13" : "migrate database to schema v13",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1315,9 +1315,9 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (database_path != NULL && mobile_export_path != NULL) {
|
if (database_path != NULL && mobile_export_path != NULL) {
|
||||||
/* CONTRACT: the body-zone importer may consume the exact retained
|
/* CONTRACT: reconciliation-aware companion importers may consume
|
||||||
* V2 snapshot as identity-reconciliation proof. argv stays
|
* the exact retained V2/V3 snapshot as stable-entry identity
|
||||||
* bounded and shell-free; all other helpers omit this pair. */
|
* proof. argv stays bounded and shell-free. */
|
||||||
execlp(
|
execlp(
|
||||||
"python3",
|
"python3",
|
||||||
"python3",
|
"python3",
|
||||||
|
|
@ -3070,7 +3070,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
status = sync_run_python_tool("import_equipment_associations.py",
|
status = sync_run_python_tool("import_equipment_associations.py",
|
||||||
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
NULL,
|
MOBILE_EXPORT_LOCAL,
|
||||||
EQUIPMENT_ASSOCIATIONS_RESULT,
|
EQUIPMENT_ASSOCIATIONS_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
|
|
||||||
|
|
@ -4,30 +4,6 @@
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
static size_t id_list_count(const char *list)
|
|
||||||
{
|
|
||||||
size_t count = 0U;
|
|
||||||
const char *at = list;
|
|
||||||
if (at == NULL || *at == '\0') return 0U;
|
|
||||||
count = 1U;
|
|
||||||
while (*at != '\0') { if (*at == '\n') ++count; ++at; }
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
static const TrainlogEquipmentKnowledge *equipment_from_list(const char **at)
|
|
||||||
{
|
|
||||||
const char *end;
|
|
||||||
char id[TRAINLOG_ID_MAX + 1U];
|
|
||||||
size_t length;
|
|
||||||
if (at == NULL || *at == NULL || **at == '\0') return NULL;
|
|
||||||
end = strchr(*at, '\n');
|
|
||||||
length = end == NULL ? strlen(*at) : (size_t)(end - *at);
|
|
||||||
if (length == 0U || length >= sizeof(id)) return NULL;
|
|
||||||
(void)memcpy(id, *at, length); id[length] = '\0';
|
|
||||||
*at = end == NULL ? *at + length : end + 1;
|
|
||||||
return trainlog_equipment_knowledge_lookup(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
void trainlog_training_exercise_context_release(TrainlogTrainingExerciseContext *context)
|
void trainlog_training_exercise_context_release(TrainlogTrainingExerciseContext *context)
|
||||||
{
|
{
|
||||||
if (context == NULL) return;
|
if (context == NULL) return;
|
||||||
|
|
@ -44,7 +20,7 @@ TrainlogStatus trainlog_training_exercise_context_load(
|
||||||
{
|
{
|
||||||
TrainlogTrainingExerciseContext result = {0};
|
TrainlogTrainingExerciseContext result = {0};
|
||||||
TrainlogStatus status;
|
TrainlogStatus status;
|
||||||
const char *equipment_at;
|
char canonical_exercise_id[TRAINLOG_ID_MAX + 1U];
|
||||||
size_t index;
|
size_t index;
|
||||||
size_t zone_count = 0U;
|
size_t zone_count = 0U;
|
||||||
bool snapshot = false;
|
bool snapshot = false;
|
||||||
|
|
@ -56,7 +32,12 @@ TrainlogStatus trainlog_training_exercise_context_load(
|
||||||
status = trainlog_database_read_snapshot_begin(database);
|
status = trainlog_database_read_snapshot_begin(database);
|
||||||
if (status != TRAINLOG_STATUS_OK) return status;
|
if (status != TRAINLOG_STATUS_OK) return status;
|
||||||
snapshot = true;
|
snapshot = true;
|
||||||
status = trainlog_database_get_exercise_profile(database, exercise_id, &result.exercise);
|
/* INVARIANT: aliases canonicalize exactly once before immutable knowledge
|
||||||
|
* joins. Persisted history/MAX APIs keep their existing canonical behavior. */
|
||||||
|
status = trainlog_database_resolve_exercise_id(database, exercise_id,
|
||||||
|
canonical_exercise_id, sizeof(canonical_exercise_id));
|
||||||
|
if (status != TRAINLOG_STATUS_OK) goto done;
|
||||||
|
status = trainlog_database_get_exercise_profile(database, canonical_exercise_id, &result.exercise);
|
||||||
if (status != TRAINLOG_STATUS_OK) goto done;
|
if (status != TRAINLOG_STATUS_OK) goto done;
|
||||||
status = trainlog_database_list_exercise_body_zones(database, exercise_id, NULL, 0U, &zone_count);
|
status = trainlog_database_list_exercise_body_zones(database, exercise_id, NULL, 0U, &zone_count);
|
||||||
if (status != TRAINLOG_STATUS_OK && !(status == TRAINLOG_STATUS_INVALID_ARGUMENT && zone_count > 0U)) goto done;
|
if (status != TRAINLOG_STATUS_OK && !(status == TRAINLOG_STATUS_INVALID_ARGUMENT && zone_count > 0U)) goto done;
|
||||||
|
|
@ -67,18 +48,20 @@ TrainlogStatus trainlog_training_exercise_context_load(
|
||||||
status=trainlog_database_list_exercise_body_zones(database,exercise_id,result.persisted_zones,zone_count,&result.persisted_zone_count);
|
status=trainlog_database_list_exercise_body_zones(database,exercise_id,result.persisted_zones,zone_count,&result.persisted_zone_count);
|
||||||
if(status!=TRAINLOG_STATUS_OK)goto done;
|
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||||
}
|
}
|
||||||
result.knowledge=trainlog_exercise_knowledge_lookup(exercise_id);
|
result.knowledge=trainlog_exercise_knowledge_lookup(canonical_exercise_id);
|
||||||
if(result.knowledge!=NULL){
|
if(result.knowledge!=NULL){
|
||||||
result.compatible_equipment_count=id_list_count(result.knowledge->equipment_ids);
|
status=trainlog_knowledge_list_equipment_for_exercise(canonical_exercise_id,NULL,0U,
|
||||||
|
&result.compatible_equipment_count);
|
||||||
|
if(status!=TRAINLOG_STATUS_OK && !(status==TRAINLOG_STATUS_INVALID_ARGUMENT &&
|
||||||
|
result.compatible_equipment_count>0U))goto done;
|
||||||
if(result.compatible_equipment_count>SIZE_MAX/sizeof(*result.compatible_equipment)){status=TRAINLOG_STATUS_INVALID_ARGUMENT;goto done;}
|
if(result.compatible_equipment_count>SIZE_MAX/sizeof(*result.compatible_equipment)){status=TRAINLOG_STATUS_INVALID_ARGUMENT;goto done;}
|
||||||
if(result.compatible_equipment_count>0U){
|
if(result.compatible_equipment_count>0U){
|
||||||
result.compatible_equipment=calloc(result.compatible_equipment_count,sizeof(*result.compatible_equipment));
|
result.compatible_equipment=calloc(result.compatible_equipment_count,sizeof(*result.compatible_equipment));
|
||||||
if(result.compatible_equipment==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
if(result.compatible_equipment==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
||||||
equipment_at=result.knowledge->equipment_ids;
|
status=trainlog_knowledge_list_equipment_for_exercise(canonical_exercise_id,
|
||||||
for(index=0U;index<result.compatible_equipment_count;++index){
|
result.compatible_equipment,result.compatible_equipment_count,
|
||||||
result.compatible_equipment[index]=equipment_from_list(&equipment_at);
|
&result.compatible_equipment_count);
|
||||||
if(result.compatible_equipment[index]==NULL){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status=trainlog_database_latest_explicit_max_context(database,exercise_id,&result.latest_max);
|
status=trainlog_database_latest_explicit_max_context(database,exercise_id,&result.latest_max);
|
||||||
|
|
|
||||||
104
tui/src/tui.c
104
tui/src/tui.c
|
|
@ -3090,6 +3090,46 @@ static void session_controller_load_equipment(TrainlogAppContext *app)
|
||||||
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
|
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool machine_exercise_compatibility(const char *exercise_id,
|
||||||
|
const char **equipment_id, TrainlogLoadMode *load_mode)
|
||||||
|
{
|
||||||
|
static const struct { const char *exercise; const char *equipment; bool assistance; } rows[] = {
|
||||||
|
{"ex_0e26c06f-a458-40a4-be20-4ed219ede30d", "plate_loaded_leg_press", false},
|
||||||
|
{"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "leg_extension", false},
|
||||||
|
{"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "rotary_torso", false},
|
||||||
|
{"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "diverging_seated_row", false},
|
||||||
|
{"ex_33f79331-871c-4eed-babe-346e53a99070", "seated_row", false},
|
||||||
|
{"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "assisted_dip_chin_machine", true},
|
||||||
|
{"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "back_extension", false},
|
||||||
|
{"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "rear_delt_pec_fly", false},
|
||||||
|
{"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "rear_delt_pec_fly", false},
|
||||||
|
{"ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "assisted_dip_chin_machine", true},
|
||||||
|
{"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "converging_shoulder_press", false},
|
||||||
|
{"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "hip_abduction", false},
|
||||||
|
{"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "seated_leg_curl", false},
|
||||||
|
{"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "lat_pull", false},
|
||||||
|
{"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "leg_press", false},
|
||||||
|
{"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "diverging_lat_pulldown", false},
|
||||||
|
{"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "prone_leg_curl", false},
|
||||||
|
{"ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "arm_curl", false}
|
||||||
|
};
|
||||||
|
size_t index;
|
||||||
|
if (strcmp(exercise_id, "ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2") == 0) {
|
||||||
|
*equipment_id = "treadmill";
|
||||||
|
*load_mode = TRAINLOG_LOAD_NONE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < sizeof(rows) / sizeof(rows[0]); ++index) {
|
||||||
|
if (strcmp(rows[index].exercise, exercise_id) == 0) {
|
||||||
|
*equipment_id = rows[index].equipment;
|
||||||
|
*load_mode = rows[index].assistance ? TRAINLOG_LOAD_ASSISTANCE :
|
||||||
|
TRAINLOG_LOAD_EXTERNAL;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static bool session_controller_choose_exercise(TrainlogSessionController *session)
|
static bool session_controller_choose_exercise(TrainlogSessionController *session)
|
||||||
{
|
{
|
||||||
TrainlogSessionDraftExercise replacement;
|
TrainlogSessionDraftExercise replacement;
|
||||||
|
|
@ -3119,6 +3159,19 @@ static bool session_controller_choose_exercise(TrainlogSessionController *sessio
|
||||||
replacement.input.recording_mode = exercise->recording_mode;
|
replacement.input.recording_mode = exercise->recording_mode;
|
||||||
replacement.input.data_fields = exercise->data_fields;
|
replacement.input.data_fields = exercise->data_fields;
|
||||||
replacement.input.load_mode = TRAINLOG_LOAD_NONE;
|
replacement.input.load_mode = TRAINLOG_LOAD_NONE;
|
||||||
|
{
|
||||||
|
const char *compatibility_equipment = NULL;
|
||||||
|
TrainlogLoadMode compatibility_load = TRAINLOG_LOAD_NONE;
|
||||||
|
/* WHY: fixed-machine identity already determines the Phase-1 legacy
|
||||||
|
* provenance. CONTRACT: custom/unresolved IDs never use name
|
||||||
|
* inference and retain the explicit compatibility editor. */
|
||||||
|
if (machine_exercise_compatibility(exercise->exercise_id,
|
||||||
|
&compatibility_equipment, &compatibility_load)) {
|
||||||
|
(void)snprintf(replacement.input.equipment_id,
|
||||||
|
sizeof(replacement.input.equipment_id), "%s", compatibility_equipment);
|
||||||
|
replacement.input.load_mode = compatibility_load;
|
||||||
|
}
|
||||||
|
}
|
||||||
draft_bind_input(&replacement);
|
draft_bind_input(&replacement);
|
||||||
if (session->replacing_occurrence) session->drafts[session->selected] = replacement;
|
if (session->replacing_occurrence) session->drafts[session->selected] = replacement;
|
||||||
else {
|
else {
|
||||||
|
|
@ -3312,12 +3365,11 @@ static bool session_controller_accept_generator(TrainlogAppContext *app)
|
||||||
|
|
||||||
static const TrainlogAppRoute shell_sections[] = {
|
static const TrainlogAppRoute shell_sections[] = {
|
||||||
TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES,
|
TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES,
|
||||||
TRAINLOG_ROUTE_EQUIPMENT, TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC,
|
TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, TRAINLOG_ROUTE_SETTINGS
|
||||||
TRAINLOG_ROUTE_SETTINGS
|
|
||||||
};
|
};
|
||||||
static const char *const shell_section_labels[] = {
|
static const char *const shell_section_labels[] = {
|
||||||
"Accueil", "Séances", "Exercices", "Équipements", "Statistiques",
|
"Accueil", "Séances", "Exercices", "Statistiques", "Synchronisation",
|
||||||
"Synchronisation", "Paramètres"
|
"Paramètres"
|
||||||
};
|
};
|
||||||
|
|
||||||
static void app_shell_destroy_surfaces(TrainlogAppContext *app)
|
static void app_shell_destroy_surfaces(TrainlogAppContext *app)
|
||||||
|
|
@ -5203,8 +5255,6 @@ static void app_shell_actions(TrainlogAppContext *app)
|
||||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
|
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
|
||||||
app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U,
|
app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U,
|
||||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES);
|
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES);
|
||||||
app_shell_add_action(app, "equipment.alias", '4', "4 Équipements", 104U,
|
|
||||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EQUIPMENT);
|
|
||||||
app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U,
|
app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U,
|
||||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY);
|
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY);
|
||||||
app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U,
|
app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U,
|
||||||
|
|
@ -6923,6 +6973,8 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
||||||
}
|
}
|
||||||
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
|
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
|
||||||
const TrainlogResolvedEquipment *item = &app->equipment_detail;
|
const TrainlogResolvedEquipment *item = &app->equipment_detail;
|
||||||
|
size_t relation_index;
|
||||||
|
int relation_row = 12;
|
||||||
trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name);
|
trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name);
|
||||||
trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id);
|
trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id);
|
||||||
trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s",
|
trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s",
|
||||||
|
|
@ -6933,6 +6985,27 @@ static void app_shell_render_content(TrainlogAppContext *app)
|
||||||
item->load_semantics[0] != '\0' ? item->load_semantics : "—");
|
item->load_semantics[0] != '\0' ? item->load_semantics : "—");
|
||||||
trainlog_surface_printf(app->content, 10, 2, "Origine : %s",
|
trainlog_surface_printf(app->content, 10, 2, "Origine : %s",
|
||||||
equipment_origin_label(item->origin));
|
equipment_origin_label(item->origin));
|
||||||
|
trainlog_surface_printf(app->content, relation_row++, 2, "Exercices possibles");
|
||||||
|
for (relation_index = 0U;
|
||||||
|
relation_index < trainlog_equipment_exercise_relation_count() &&
|
||||||
|
relation_row < app->layout.content.height; ++relation_index) {
|
||||||
|
const TrainlogEquipmentExerciseRelation *relation =
|
||||||
|
trainlog_equipment_exercise_relation_at(relation_index);
|
||||||
|
const TrainlogExerciseKnowledge *exercise;
|
||||||
|
const char *confidence;
|
||||||
|
if (relation == NULL || strcmp(relation->equipment_id,
|
||||||
|
item->equipment_id) != 0) continue;
|
||||||
|
exercise = trainlog_exercise_knowledge_lookup(relation->exercise_id);
|
||||||
|
if (exercise == NULL) continue;
|
||||||
|
confidence = strcmp(relation->confidence, "high") == 0 ? "élevée" :
|
||||||
|
strcmp(relation->confidence, "moderate") == 0 ? "modérée" : "incertaine";
|
||||||
|
trainlog_surface_printf(app->content, relation_row++, 4,
|
||||||
|
"> %.38s · confiance %s · sources vérifiées",
|
||||||
|
exercise->exercise_name, confidence);
|
||||||
|
}
|
||||||
|
if (relation_row == 13)
|
||||||
|
trainlog_surface_printf(app->content, relation_row, 4,
|
||||||
|
"Aucune association vérifiée; aucune anatomie déduite du nom.");
|
||||||
} else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
|
} else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
|
||||||
const TrainlogExercise *item = &app->exercise_detail;
|
const TrainlogExercise *item = &app->exercise_detail;
|
||||||
const TrainlogBodyZone *primary = NULL;
|
const TrainlogBodyZone *primary = NULL;
|
||||||
|
|
@ -8963,6 +9036,25 @@ body_global_unhandled:;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shell_nonknowledge_input:
|
shell_nonknowledge_input:
|
||||||
|
if (app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL &&
|
||||||
|
(key == TRAINLOG_KEY_ENTER || key == '\n')) {
|
||||||
|
size_t relation_index;
|
||||||
|
for (relation_index = 0U;
|
||||||
|
relation_index < trainlog_equipment_exercise_relation_count();
|
||||||
|
++relation_index) {
|
||||||
|
const TrainlogEquipmentExerciseRelation *relation =
|
||||||
|
trainlog_equipment_exercise_relation_at(relation_index);
|
||||||
|
if (relation == NULL || strcmp(relation->equipment_id,
|
||||||
|
app->equipment_detail.equipment_id) != 0) continue;
|
||||||
|
if (trainlog_database_get_exercise_profile(app->database,
|
||||||
|
relation->exercise_id, &app->exercise_detail) == TRAINLOG_STATUS_OK) {
|
||||||
|
app_shell_load_exercise_detail_metadata(app);
|
||||||
|
(void)trainlog_navigation_open(&app->navigation,
|
||||||
|
TRAINLOG_ROUTE_EXERCISE_DETAIL, app->exercise_detail.exercise_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE ||
|
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE ||
|
||||||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) {
|
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) {
|
||||||
size_t page = app->layout.content.height > 10
|
size_t page = app->layout.content.height > 10
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,11 @@ static int relation_and_filter_contract(void)
|
||||||
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK);
|
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 1U && strcmp(results[0].exercise_id, created.exercise_id) == 0);
|
CHECK(count == 1U && strcmp(results[0].exercise_id, created.exercise_id) == 0);
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "", "chest",
|
CHECK(trainlog_database_list_exercises_filtered(database, "", "chest",
|
||||||
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
|
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U);
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
|
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
|
||||||
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
|
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U);
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
|
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
|
||||||
false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
|
false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "chest", "back",
|
CHECK(trainlog_database_list_exercises_filtered(database, "chest", "back",
|
||||||
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
|
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "", "back",
|
CHECK(trainlog_database_list_exercises_filtered(database, "", "back",
|
||||||
|
|
@ -157,7 +157,8 @@ static int relation_and_filter_contract(void)
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_list_exercises_filtered(database, "", NULL,
|
CHECK(trainlog_database_list_exercises_filtered(database, "", NULL,
|
||||||
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
|
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 3U);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
database = NULL;
|
database = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
|
|
@ -186,6 +187,7 @@ static int migration_preserves_identity_and_history(void)
|
||||||
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
|
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
|
||||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE);"
|
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE);"
|
||||||
|
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,entry_id TEXT,equipment_id TEXT);"
|
||||||
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
|
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
|
||||||
"'Leg press','leg press','reps','sets',0);"
|
"'Leg press','leg press','reps','sets',0);"
|
||||||
"INSERT INTO sessions VALUES(3,'se_preserved');"
|
"INSERT INTO sessions VALUES(3,'se_preserved');"
|
||||||
|
|
@ -193,7 +195,7 @@ static int migration_preserves_identity_and_history(void)
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 12);
|
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 13);
|
||||||
CHECK(trainlog_database_list_exercise_body_zones(database,
|
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 2U);
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,9 @@ static bool test_transaction_rollback(void)
|
||||||
trainlog_database_exercise_count(database, &count) ==
|
trainlog_database_exercise_count(database, &count) ==
|
||||||
TRAINLOG_STATUS_OK
|
TRAINLOG_STATUS_OK
|
||||||
);
|
);
|
||||||
CHECK(count == 0U);
|
/* Schema v13 seeds the five frozen supplied machine identities. The
|
||||||
|
* rolled-back custom row must not change that baseline. */
|
||||||
|
CHECK(count == 5U);
|
||||||
|
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -394,7 +396,7 @@ static bool test_exercise_merge_aliases_and_conflicts(void)
|
||||||
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
sizeof(canonical)) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(strcmp(canonical, "ex_final") == 0);
|
CHECK(strcmp(canonical, "ex_final") == 0);
|
||||||
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 7U);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,14 +164,15 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
|
||||||
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
|
||||||
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
|
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
|
||||||
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
|
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
|
||||||
"session_row_id INTEGER,recording_mode TEXT);"
|
"session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT,"
|
||||||
|
"equipment_id TEXT,recording_mode TEXT);"
|
||||||
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
|
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
|
||||||
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
|
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
|
||||||
"duration_seconds INTEGER,weight_kg REAL);"
|
"duration_seconds INTEGER,weight_kg REAL);"
|
||||||
"INSERT INTO sessions VALUES(1,'max_test');"
|
"INSERT INTO sessions VALUES(1,'max_test');"
|
||||||
"INSERT INTO session_exercises VALUES(10,1,'sets');"
|
"INSERT INTO session_exercises VALUES(10,1,NULL,NULL,NULL,'sets');"
|
||||||
"INSERT INTO session_exercises VALUES(11,1,'sets');"
|
"INSERT INTO session_exercises VALUES(11,1,NULL,NULL,NULL,'sets');"
|
||||||
"INSERT INTO session_exercises VALUES(12,1,'sets');"
|
"INSERT INTO session_exercises VALUES(12,1,NULL,NULL,NULL,'sets');"
|
||||||
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
|
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
|
||||||
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
|
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
|
||||||
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v12: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v13: SQLite rc=",
|
||||||
strlen("migrate database to schema v12: SQLite rc=")) == 0);
|
strlen("migrate database to schema v13: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
|
|
||||||
static bool test_newer_schema_has_application_diagnostic(void)
|
static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
{
|
{
|
||||||
char path[] = "/tmp/trainlog-schema-v13-XXXXXX";
|
char path[] = "/tmp/trainlog-schema-v14-XXXXXX";
|
||||||
char diagnostic[256];
|
char diagnostic[256];
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=13;", NULL, NULL, NULL) == SQLITE_OK);
|
CHECK(sqlite3_exec(raw, "PRAGMA user_version=14;", NULL, NULL, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 13 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 14 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v12") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v13") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
|
|
@ -403,7 +403,7 @@ static bool database_state(
|
||||||
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
|
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
|
||||||
&statement, NULL) == SQLITE_OK);
|
&statement, NULL) == SQLITE_OK);
|
||||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||||
CHECK(sqlite3_column_int(statement, 0) == 1);
|
CHECK(sqlite3_column_int(statement, 0) == 6);
|
||||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||||
statement = NULL;
|
statement = NULL;
|
||||||
if (source_should_resolve) {
|
if (source_should_resolve) {
|
||||||
|
|
@ -493,9 +493,15 @@ static bool run_success_case(
|
||||||
CHECK(prepare_database(case_root, persistent_alias, false,
|
CHECK(prepare_database(case_root, persistent_alias, false,
|
||||||
database_path, sizeof(database_path)));
|
database_path, sizeof(database_path)));
|
||||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
||||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
{
|
||||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||||
|
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
(void)fprintf(stderr, "sync failure: %s\n", report.error);
|
||||||
|
}
|
||||||
|
CHECK(status == TRAINLOG_STATUS_OK);
|
||||||
|
}
|
||||||
CHECK(report.success);
|
CHECK(report.success);
|
||||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||||
CHECK(successful_journal(case_root, report.sync_id));
|
CHECK(successful_journal(case_root, report.sync_id));
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ int main(void)
|
||||||
size_t index;
|
size_t index;
|
||||||
int rotary_capability_found = 0;
|
int rotary_capability_found = 0;
|
||||||
const TrainlogKnowledgeBodyZoneAudit *audit;
|
const TrainlogKnowledgeBodyZoneAudit *audit;
|
||||||
|
const TrainlogEquipmentKnowledge *equipment_rows[32];
|
||||||
|
|
||||||
CHECK(trainlog_knowledge_reference_count() == 23U);
|
CHECK(trainlog_knowledge_reference_count() == 23U);
|
||||||
CHECK(trainlog_knowledge_muscle_count() == 53U);
|
CHECK(trainlog_knowledge_muscle_count() == 53U);
|
||||||
|
|
@ -52,6 +53,20 @@ int main(void)
|
||||||
CHECK(trainlog_knowledge_reference_lookup("openstax_upper") != NULL);
|
CHECK(trainlog_knowledge_reference_lookup("openstax_upper") != NULL);
|
||||||
CHECK(trainlog_equipment_knowledge_lookup("rear_delt_pec_fly") != NULL);
|
CHECK(trainlog_equipment_knowledge_lookup("rear_delt_pec_fly") != NULL);
|
||||||
CHECK(trainlog_exercise_knowledge_lookup("ex_unknown") == NULL);
|
CHECK(trainlog_exercise_knowledge_lookup("ex_unknown") == NULL);
|
||||||
|
CHECK(trainlog_equipment_exercise_relation_count() == 17U);
|
||||||
|
CHECK(trainlog_knowledge_list_exercises_for_equipment("leg_press", rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 1U && strcmp(rows[0]->exercise_id, leg_press) == 0);
|
||||||
|
CHECK(trainlog_knowledge_list_exercises_for_equipment("functional_trainer", rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 0U);
|
||||||
|
CHECK(trainlog_knowledge_list_exercises_for_equipment("assisted_dip_chin_machine", rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 0U);
|
||||||
|
CHECK(trainlog_knowledge_list_exercises_for_equipment("seated_leg_curl", rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 1U && strcmp(rows[0]->exercise_id, "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163") == 0);
|
||||||
|
CHECK(trainlog_knowledge_list_equipment_for_exercise(leg_press, equipment_rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(count == 2U && strcmp(equipment_rows[0]->equipment_id, "leg_press") == 0);
|
||||||
|
CHECK(trainlog_knowledge_list_equipment_for_body_zone("chest", true, equipment_rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||||
|
for (index = 0U; index < count; ++index)
|
||||||
|
CHECK(strcmp(equipment_rows[index]->equipment_id, "rear_delt_pec_fly") != 0);
|
||||||
|
|
||||||
record = trainlog_exercise_knowledge_lookup(rear_delt);
|
record = trainlog_exercise_knowledge_lookup(rear_delt);
|
||||||
CHECK(record != NULL && record->interpretation != NULL);
|
CHECK(record != NULL && record->interpretation != NULL);
|
||||||
|
|
|
||||||
|
|
@ -548,7 +548,7 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
TrainlogAppContext app;
|
TrainlogAppContext app;
|
||||||
TrainlogExercise created;
|
TrainlogExercise created;
|
||||||
TrainlogExercise listed[4];
|
TrainlogExercise listed[8];
|
||||||
size_t count = 0U;
|
size_t count = 0U;
|
||||||
const char *name = "Course inclinée";
|
const char *name = "Course inclinée";
|
||||||
size_t index;
|
size_t index;
|
||||||
|
|
@ -574,7 +574,7 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
||||||
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
||||||
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
|
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
|
||||||
CHECK(app.session.phase == TRAINLOG_SESSION_EXERCISE_PICKER);
|
CHECK(app.session.phase == TRAINLOG_SESSION_EXERCISE_PICKER);
|
||||||
CHECK(app.session.picker_count == 1U);
|
CHECK(app.session.picker_count == 6U);
|
||||||
created = app.session.picker[app.session.picker_selected];
|
created = app.session.picker[app.session.picker_selected];
|
||||||
CHECK(created.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
|
CHECK(created.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
|
||||||
CHECK(created.tracking_mode == TRAINLOG_TRACKING_DURATION);
|
CHECK(created.tracking_mode == TRAINLOG_TRACKING_DURATION);
|
||||||
|
|
@ -588,9 +588,9 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
|
||||||
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
|
||||||
CHECK(strcmp(app.exercise_detail.exercise_id, created.exercise_id) == 0);
|
CHECK(strcmp(app.exercise_detail.exercise_id, created.exercise_id) == 0);
|
||||||
CHECK(strcmp(app.exercise_detail.name, "Course renommée") == 0);
|
CHECK(strcmp(app.exercise_detail.name, "Course renommée") == 0);
|
||||||
CHECK(trainlog_database_list_exercises(database, listed, 4U,
|
CHECK(trainlog_database_list_exercises(database, listed, 8U,
|
||||||
&count) == TRAINLOG_STATUS_OK);
|
&count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 1U && strcmp(listed[0].exercise_id, created.exercise_id) == 0);
|
CHECK(count == 6U);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -618,7 +618,7 @@ static bool test_shell_navigation_focus_consumes_input(void)
|
||||||
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
|
||||||
app.navigation_selected = 3U;
|
app.navigation_selected = 3U;
|
||||||
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
|
||||||
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT);
|
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_STATS);
|
||||||
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
|
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
|
||||||
trainlog_database_close(database);
|
trainlog_database_close(database);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1952,15 +1952,15 @@ static bool test_stats_detail_charts_and_global_zone_projection(void)
|
||||||
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
||||||
app.dashboard.period = TRAINLOG_STATS_ALL;
|
app.dashboard.period = TRAINLOG_STATS_ALL;
|
||||||
app_shell_load_dashboard(&app);
|
app_shell_load_dashboard(&app);
|
||||||
CHECK(!app.dashboard.error && app.dashboard.zone_count == 3U);
|
CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U);
|
||||||
for (index = 0U; index < app.dashboard.zone_count; ++index) {
|
for (index = 0U; index < app.dashboard.zone_count; ++index) {
|
||||||
chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 &&
|
chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 &&
|
||||||
app.dashboard.zones[index].count == 1U);
|
app.dashboard.zones[index].count == 2U);
|
||||||
back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 &&
|
back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 &&
|
||||||
app.dashboard.zones[index].count == 1U);
|
app.dashboard.zones[index].count == 2U);
|
||||||
unclassified = unclassified ||
|
unclassified = unclassified ||
|
||||||
(strcmp(app.dashboard.zones[index].label, "Non classés") == 0 &&
|
(strcmp(app.dashboard.zones[index].label, "Non classés") == 0 &&
|
||||||
app.dashboard.zones[index].count == 1U);
|
app.dashboard.zones[index].count == 3U);
|
||||||
}
|
}
|
||||||
CHECK(chest && back && unclassified);
|
CHECK(chest && back && unclassified);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue