feat(exercises): add hierarchical body zones

This commit is contained in:
fy59 2026-09-09 14:37:16 +02:00
parent 1b08db009e
commit 1aad7b48b3
54 changed files with 4264 additions and 213 deletions

View file

@ -9,6 +9,24 @@ Detailed implementation chronology remains available in Git history and
### Added ### Added
- `BODY_ZONES_V1`: the canonical `catalog/body-zones-v1.json` taxonomy with
stable IDs, French display metadata, hierarchy, deterministic sort order and
exact stable-exercise-ID migration evidence;
- one primary plus multiple distinct secondary body-zone relations on desktop
and Android, with explicit unclassified history, transactional creation/edit,
exercise detail summaries, parent/descendant filters, prefix-search
composition, and custom-exercise support;
- one strict bidirectional `trainlog-exercise-body-zones` v1 companion carrying
`exercise_id`, nullable primary ID and ordered secondary IDs; replay is
idempotent, one-sided edits reconcile, simultaneous divergence conflicts,
secondary lists are never unioned automatically, and the publishing peer
records the exact published snapshot as its common baseline;
- generator-ready read composition for descendant and primary-only exercise
selection, direct zone relations, existing performance history and explicit
MAX history without duplicating historical or MAX data;
- taxonomy, schema migration, relation constraint, filtering, identity-merge,
reopen, companion replay/update/conflict and Android repository regressions;
- explicit MAX result mode for `max_test` sessions: exercise, optional - explicit MAX result mode for `max_test` sessions: exercise, optional
equipment context and positive `max_weight_kg`, with no synthetic set or equipment context and positive `max_weight_kg`, with no synthetic set or
repetition; repetition;
@ -91,6 +109,15 @@ Detailed implementation chronology remains available in Git history and
### Changed ### Changed
- desktop schema v11 additively introduces `exercise_body_zones` and its
internal sync-baseline table; Android schema v10 introduces the equivalent
relations. Both migrations seed only manifest mappings proven by stable
`exercise_id`, leave uncertain exercises unclassified, and preserve all
session, occurrence, set, MAX, equipment and body-observation identities;
- Android and TUI exercise catalog workflows now select translated manifest
values, derive group labels, exclude a primary from secondary selection and
combine hierarchy-aware zone filtering with normalized prefix search;
- desktop schema v10 losslessly rebuilds only `performed_sets` to accept an - desktop schema v10 losslessly rebuilds only `performed_sets` to accept an
explicit zero actual `weight_kg`; historic NULL and positive actual loads explicit zero actual `weight_kg`; historic NULL and positive actual loads
remain unchanged, while planned targets and explicit MAX results stay remain unchanged, while planned targets and explicit MAX results stay
@ -135,6 +162,14 @@ Detailed implementation chronology remains available in Git history and
### Fixed ### Fixed
- a newly published custom-exercise zone state now establishes the publisher's
comparison baseline as well as the receiver's; a later peer-only edit no
longer produces a false simultaneous-conflict result;
- secondary-only body-zone states are rejected consistently by C, Kotlin and
Python boundaries instead of being persisted but hidden by exercise detail;
- touched migration/custom-equipment/body-metric/TUI C regressions now return
a failing process status from `main()`; the repaired workflow fixture uses a
real in-memory database instead of passing an invalid null handle;
- synchronization summaries no longer add exercise reconciliations to the - synchronization summaries no longer add exercise reconciliations to the
`exercices ajoutés` value. A compatible different-ID lookup that makes no `exercices ajoutés` value. A compatible different-ID lookup that makes no
persistent change is reported as an idempotent skip, while real insertions persistent change is reported as an idempotent skip, while real insertions
@ -174,22 +209,22 @@ Current validated baseline:
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V10=PASS DESKTOP_SCHEMA_V11=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V10=PASS
ANDROID_TEST_DEBUG_UNIT=PASS ANDROID_TEST_DEBUG_UNIT=44/44 PASS (real v9 fixture enabled)
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS ANDROID_MAX_V9_REAL_DATA_MIGRATION=PASS
ANDROID_MAX_V9_INSTALL_ADB=PASS ANDROID_MAX_V9_INSTALL_ADB=PASS
USB_MTP_DETECTION=PASS USB_MTP_DETECTION=PASS
MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS MTP_HARDWARE_ROUNDTRIP=HISTORICAL_PASS
CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=HISTORICAL_PASS
REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS BODY_ZONES_DESKTOP_REAL_DATABASE=PASS
REAL_DATABASE_APPLICATION=BLOCKED_BY_SANDBOX_WRITE_BOUNDARY BODY_ZONES_ANDROID_DEVICE=PASS
ANDROID_TO_PC_MTP=HISTORICAL_PASS ANDROID_TO_PC_MTP=HISTORICAL_PASS
PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS PC_TO_ANDROID_MTP_PUBLISH=HISTORICAL_PASS
@ -204,8 +239,27 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
``` ```
For Body Zones V1, the real desktop database was backed up coherently and
migrated v10 -> v11 through the production binary. All eight pre-existing
tables compare equal row-for-row with the backup; integrity/FK checks pass and
the migration adds 32 direct relations for 20 of 23 exercises. On the Samsung
SM-G990B, the matching signed APK was installed with `adb install -r`; the real
Android database migrated v9 -> v10 with every pre-existing application row
unchanged, 32 relations for the same 20 exercises and the expected three
unclassified exercises. The Android zone/detail/filter/edit-cancel matrix and
two live PC <-> Android MTP passes completed successfully. The second pass
reported no additions or reconciliations and left every Android application
table and the semantic Body Zones companion unchanged. Scoped storage had
published the requests as exact `(N).json` collision siblings; the shared
engine now selects the newest request with the same deterministic helper used
for other Android-originated artifacts.
### Measured max v1 ### Measured max v1
Added: Added:

View file

@ -16,9 +16,9 @@ desktop.
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V10=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V10=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
@ -37,8 +37,12 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
``` ```
@ -125,6 +129,16 @@ and historical occurrence snapshots remain unchanged; absent optional values
stay absent. Incomparable profiles remain explicit conflicts. Name equality stay absent. Incomparable profiles remain explicit conflicts. Name equality
alone is never sufficient. alone is never sufficient.
Exercise body zones are independent metadata sourced from the single canonical
[`catalog/body-zones-v1.json`](catalog/body-zones-v1.json) manifest. An exercise
may have one primary assignable zone and several distinct secondary zones.
Secondary relations require that primary; the explicit unclassified state has
no relations at all.
`upper_body` and `lower_body` are hierarchy groups used for display and
descendant-aware filtering; they are never duplicated as stored relations.
`full_body` and `core` remain autonomous. Historical exercises without an
objective stable-ID mapping remain visible as **Non renseignés**.
## Repository layout ## Repository layout
```text ```text
@ -132,6 +146,7 @@ android/ native Kotlin/Compose Android client
tui/ C17 Notcurses desktop application and core tui/ C17 Notcurses desktop application and core
docs/ canonical project documentation docs/ canonical project documentation
format/ frozen Trainlog JSON v1 schema material format/ frozen Trainlog JSON v1 schema material
catalog/ canonical versioned equipment and body-zone manifests
examples/ valid frozen-format examples examples/ valid frozen-format examples
tests/ fixtures and cross-component tests tests/ fixtures and cross-component tests
tools/ validators, import/export helpers, sync daemon tooling tools/ validators, import/export helpers, sync daemon tooling
@ -163,11 +178,11 @@ 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 v9. Its additive v4 -> v9 chain adds the shared The current Android schema is v10. Its additive v4 -> v10 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 without recreating completed history or stable-source Test max resumption, then direct primary/secondary body-zone
discarding the active draft. relations, without recreating completed history or discarding the active draft.
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
unchanged; completed history, an active draft, and synchronization therefore unchanged; completed history, an active draft, and synchronization therefore
@ -185,7 +200,7 @@ cd android
printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
./gradlew assembleDebug ./gradlew testDebugUnitTest assembleDebug
``` ```
## Android-triggered synchronization ## Android-triggered synchronization
@ -225,6 +240,8 @@ runs, a receipt is returned to Android, and the PC catalog is applied locally.
The active completed-session exchange is V2 and preserves occurrence The active completed-session exchange is V2 and preserves occurrence
`entry_id`, per-set weights and equipment associations. Frozen V1 artifacts `entry_id`, per-set weights and equipment associations. Frozen V1 artifacts
remain readable as legacy artifacts; they are not silently redefined as V2. remain readable as legacy artifacts; they are not silently redefined as V2.
Exercise-zone metadata travels separately in the sole bidirectional
`trainlog-exercise-body-zones-v1.json` companion.
## Documentation ## Documentation
@ -296,5 +313,5 @@ BODY_ANALYTICS_V1=PASS
BODY_COMPOSITION_ESTIMATE=PASS BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
``` ```

View file

@ -29,9 +29,10 @@ android {
sourceSets { sourceSets {
getByName("main") { getByName("main") {
/* CONTRACT: this repository-level manifest is the one canonical /* CONTRACT: repository-level equipment and body-zone manifests
* equipment source. Android must not fork it into Kotlin constants. */ * are the canonical shared sources. Android must not fork either
assets.srcDir("../../catalog") * taxonomy into Kotlin constants. */
assets.directories.add("../../catalog")
} }
} }

View file

@ -0,0 +1,132 @@
package com.labfytools.trainlog.data
import android.content.Context
import org.json.JSONObject
enum class BodyZoneKind { GROUP, LEAF, STANDALONE }
data class BodyZone(
val zoneId: String,
val displayName: String,
val parentZoneId: String?,
val sortOrder: Int,
val kind: BodyZoneKind,
)
data class InitialExerciseBodyZones(
val exerciseId: String,
val primaryZoneId: String,
val secondaryZoneIds: List<String>,
)
/**
* CONTRACT: Android reads the repository-level body-zone manifest directly.
* There is deliberately no Kotlin fallback list: IDs, hierarchy and initial
* identity mappings cannot silently diverge from the generated C catalogue.
*/
class BodyZoneCatalog private constructor(
val zones: List<BodyZone>,
val initialMappings: List<InitialExerciseBodyZones>,
) {
private val byId = zones.associateBy { it.zoneId }
fun lookup(zoneId: String): BodyZone? = byId[zoneId]
fun children(zoneId: String): List<BodyZone> =
zones.filter { it.parentZoneId == zoneId }
fun ancestors(zoneId: String): List<BodyZone> = buildList {
var current = lookup(zoneId)
while (current?.parentZoneId != null) {
current = lookup(current.parentZoneId)
checkNotNull(current) { "Parent de zone absent" }
add(current)
}
}
fun descendantsAndSelf(zoneId: String): Set<String> {
checkNotNull(lookup(zoneId)) { "zone_id inconnu: $zoneId" }
val output = linkedSetOf(zoneId)
var changed = true
while (changed) {
changed = false
zones.forEach { zone ->
if (zone.parentZoneId in output && output.add(zone.zoneId)) changed = true
}
}
return output
}
companion object {
private const val ASSET_NAME = "body-zones-v1.json"
fun load(context: Context): BodyZoneCatalog {
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
JSONObject(it.readText())
}
check(root.keys().asSequence().toSet() == setOf(
"format", "version", "zones", "exercise_mappings",
))
check(root.getString("format") == "trainlog-body-zone-catalog")
check(root.getInt("version") == 1)
val array = root.getJSONArray("zones")
val seenIds = mutableSetOf<String>()
val seenOrders = mutableSetOf<Int>()
val zones = List(array.length()) { index ->
val item = array.getJSONObject(index)
check(item.keys().asSequence().toSet() == setOf(
"zone_id", "display_name", "parent_zone_id", "sort_order", "kind",
))
val zone = BodyZone(
zoneId = item.getString("zone_id"),
displayName = item.getString("display_name").trim(),
parentZoneId = if (item.isNull("parent_zone_id")) null else item.getString("parent_zone_id"),
sortOrder = item.getInt("sort_order"),
kind = BodyZoneKind.valueOf(item.getString("kind").uppercase()),
)
check(zone.zoneId.matches(Regex("[a-z][a-z0-9_]*")))
check(zone.displayName.isNotEmpty() && zone.sortOrder >= 0)
check(seenIds.add(zone.zoneId) && seenOrders.add(zone.sortOrder))
zone
}.sortedBy { it.sortOrder }
val byId = zones.associateBy { it.zoneId }
zones.forEach { zone ->
check(zone.parentZoneId == null || zone.parentZoneId in byId)
val seen = mutableSetOf(zone.zoneId)
var parent = zone.parentZoneId
while (parent != null) {
check(seen.add(parent)) { "Boucle dans la hiérarchie corporelle" }
parent = byId.getValue(parent).parentZoneId
}
check((zone.kind == BodyZoneKind.GROUP) == zones.any { it.parentZoneId == zone.zoneId })
}
val mappingsArray = root.getJSONArray("exercise_mappings")
val seenExercises = mutableSetOf<String>()
val mappings = List(mappingsArray.length()) { index ->
val item = mappingsArray.getJSONObject(index)
check(item.keys().asSequence().toSet() == setOf(
"exercise_id", "exercise_name", "primary_zone_id",
"secondary_zone_ids", "decision_source",
))
val secondary = item.getJSONArray("secondary_zone_ids")
val mapping = InitialExerciseBodyZones(
exerciseId = item.getString("exercise_id"),
primaryZoneId = item.getString("primary_zone_id"),
secondaryZoneIds = List(secondary.length()) { secondary.getString(it) },
)
check(seenExercises.add(mapping.exerciseId))
check(item.getString("exercise_name").isNotBlank())
check(item.getString("decision_source").isNotBlank())
check(byId[mapping.primaryZoneId]?.kind != null &&
byId.getValue(mapping.primaryZoneId).kind != BodyZoneKind.GROUP)
check(mapping.secondaryZoneIds.toSet().size == mapping.secondaryZoneIds.size)
check(mapping.primaryZoneId !in mapping.secondaryZoneIds)
check(mapping.secondaryZoneIds.all {
byId[it]?.kind != null && byId.getValue(it).kind != BodyZoneKind.GROUP
})
mapping
}
return BodyZoneCatalog(zones, mappings)
}
}
}

View file

@ -142,11 +142,14 @@ class SyncCatalogInbox(
is PcCatalogImportResult.Applied -> is PcCatalogImportResult.Applied ->
when (val sessions = importPcSessions(directory)) { when (val sessions = importPcSessions(directory)) {
null -> when (val equipment = importPcEquipmentAssociations(directory)) { null -> when (val equipment = importPcEquipmentAssociations(directory)) {
null -> when (val bodyZones = importPcBodyZones(directory)) {
null -> CatalogInboxResult.Imported( null -> CatalogInboxResult.Imported(
imported = result.imported, imported = result.imported,
reconciled = result.reconciled, reconciled = result.reconciled,
skipped = result.skipped, skipped = result.skipped,
) )
else -> CatalogInboxResult.Error(bodyZones)
}
else -> CatalogInboxResult.Error(equipment) else -> CatalogInboxResult.Error(equipment)
} }
else -> CatalogInboxResult.Error(sessions) else -> CatalogInboxResult.Error(sessions)
@ -186,6 +189,25 @@ class SyncCatalogInbox(
} catch (error: Exception) { error.message ?: "Import séances V2 impossible." } } catch (error: Exception) { error.message ?: "Import séances V2 impossible." }
} }
private fun importPcBodyZones(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null
return try {
val json = appContext.contentResolver.openInputStream(file.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture zones corporelles impossible."
when (val result = repository.applyExerciseBodyZonesJson(json)) {
is ExerciseBodyZoneImportResult.Applied -> null
is ExerciseBodyZoneImportResult.Invalid -> result.message
is ExerciseBodyZoneImportResult.Conflict ->
"Conflit de zones corporelles : ${result.exerciseId}"
ExerciseBodyZoneImportResult.DatabaseError ->
"Erreur base locale zones corporelles."
}
} catch (error: Exception) {
error.message ?: "Import zones corporelles impossible."
}
}
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? { private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
return try { return try {

View file

@ -39,19 +39,21 @@ class SyncExporter(
return SyncExportResult.Unsupported return SyncExportResult.Unsupported
} }
/* CONTRACT: capture all three artifacts before publishing any of /* CONTRACT: capture all four artifacts before publishing any of
* them. The files are separate for compatibility, but a user edit * them. The files are separate for compatibility, but a user edit
* must not make a newly-written V2 reference a definition assembled * must not make a newly-written V2 reference a definition assembled
* from a different logical export state. */ * from a different logical export state. */
val definitionsJson: String val definitionsJson: String
val mobileJson: String val mobileJson: String
val associationsJson: String val associationsJson: String
val bodyZonesJson: String
try { try {
definitionsJson = repository.buildEquipmentDefinitionsJson() definitionsJson = repository.buildEquipmentDefinitionsJson()
/* V2 is the authoritative mobile session exchange. V1 remains /* V2 is the authoritative mobile session exchange. V1 remains
* readable by desktop for historic devices but is not published. */ * readable by desktop for historic devices but is not published. */
mobileJson = repository.buildMobileExportV2Json() mobileJson = repository.buildMobileExportV2Json()
associationsJson = repository.buildEquipmentAssociationsJson() associationsJson = repository.buildEquipmentAssociationsJson()
bodyZonesJson = repository.buildExerciseBodyZonesJson()
} catch (error: Exception) { } catch (error: Exception) {
return SyncExportResult.Error( return SyncExportResult.Error(
error.message ?: "Préparation de l'export impossible.", error.message ?: "Préparation de l'export impossible.",
@ -188,6 +190,25 @@ class SyncExporter(
) )
} }
val bodyZonesError = writeBodyZones(bodyZonesJson)
if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError)
/* CONTRACT: publication, not JSON construction, establishes the
* common sync ancestor. Applying the exact local snapshot can only
* record equal baselines; the strict reconciler never unions zones. */
when (val acknowledgement = repository.applyExerciseBodyZonesJson(bodyZonesJson)) {
is ExerciseBodyZoneImportResult.Applied -> Unit
is ExerciseBodyZoneImportResult.Conflict ->
return SyncExportResult.Error(
"Conflit pendant l'enregistrement de la baseline zones : " +
acknowledgement.exerciseId,
)
is ExerciseBodyZoneImportResult.Invalid ->
return SyncExportResult.Error(acknowledgement.message)
ExerciseBodyZoneImportResult.DatabaseError ->
return SyncExportResult.Error(
"Enregistrement de la baseline zones impossible.",
)
}
val companionError = writeEquipmentAssociations(associationsJson) val companionError = writeEquipmentAssociations(associationsJson)
if (companionError != null) { if (companionError != null) {
return SyncExportResult.Error(companionError) return SyncExportResult.Error(companionError)
@ -273,6 +294,34 @@ class SyncExporter(
} }
} }
/** One directional-neutral companion is used by both peers. */
private fun writeBodyZones(json: String): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
val name = "trainlog-exercise-body-zones-v1.json"
val existing = findExisting(collection, name, relativePath)
val created = existing == null
val uri = existing ?: resolver.insert(collection, ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}) ?: return "Création des zones corporelles impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(json.toByteArray(Charsets.UTF_8)); it.flush()
} ?: return "Écriture des zones corporelles impossible."
if (created) resolver.update(uri, ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}, null, null)
null
} catch (error: Exception) {
if (created) resolver.delete(uri, null, null)
error.message ?: "Export des zones corporelles impossible."
}
}
private fun findExisting( private fun findExisting(
collection: Uri, collection: Uri,
displayName: String, displayName: String,

View file

@ -40,6 +40,8 @@ sealed interface CreateExerciseResult {
data object Invalid : data object Invalid :
CreateExerciseResult CreateExerciseResult
data class DatabaseError(val message: String) : CreateExerciseResult
} }
sealed interface EditExerciseResult { sealed interface EditExerciseResult {
@ -92,6 +94,17 @@ sealed interface EquipmentDefinitionImportResult {
data object DatabaseError : EquipmentDefinitionImportResult data object DatabaseError : EquipmentDefinitionImportResult
} }
sealed interface ExerciseBodyZoneImportResult {
data class Applied(
val updated: Int,
val skipped: Int,
val keptLocal: Int,
) : ExerciseBodyZoneImportResult
data class Invalid(val message: String) : ExerciseBodyZoneImportResult
data class Conflict(val exerciseId: String) : ExerciseBodyZoneImportResult
data object DatabaseError : ExerciseBodyZoneImportResult
}
sealed interface MobileSessionImportResult { sealed interface MobileSessionImportResult {
/** CONTRACT: counters describe persistent mutations, not artifact size. */ /** CONTRACT: counters describe persistent mutations, not artifact size. */
data class Applied( data class Applied(
@ -177,6 +190,7 @@ class TrainlogRepository(
ANDROID_DATABASE_NAME, ANDROID_DATABASE_NAME,
) { ) {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext
private val bodyZones = BodyZoneCatalog.load(applicationContext)
private val database = private val database =
TrainlogDatabaseHelper( TrainlogDatabaseHelper(
applicationContext, applicationContext,
@ -242,26 +256,60 @@ class TrainlogRepository(
} catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") } } catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") }
} }
fun listExercises(): List<ExerciseProfile> { /** Return manifest sort order; definitions are immutable application assets. */
fun listBodyZones(): List<BodyZone> = bodyZones.zones
/** Stable-ID lookup; null means the ID is not part of taxonomy V1. */
fun bodyZone(zoneId: String): BodyZone? = bodyZones.lookup(zoneId)
/** Nearest parent first; the manifest loader has already rejected cycles. */
fun bodyZoneAncestors(zoneId: String): List<BodyZone> = bodyZones.ancestors(zoneId)
/**
* SQL-backed normalized-prefix and body-zone query.
*
* CONTRACT: a parent includes manifest descendants only when requested;
* `primaryOnly` excludes secondary participation and `unclassifiedOnly`
* selects exercises with no direct relation. An unknown zone ID is a
* programmer error. Returned profiles own deterministic relation lists.
*/
fun listExercises(
query: String = "",
zoneId: String? = null,
includeDescendants: Boolean = true,
primaryOnly: Boolean = false,
unclassifiedOnly: Boolean = false,
): List<ExerciseProfile> {
require(!(unclassifiedOnly && zoneId != null)) {
"zoneId et unclassifiedOnly sont mutuellement exclusifs"
}
val output = val output =
mutableListOf<ExerciseProfile>() mutableListOf<ExerciseProfile>()
val normalizedPrefix = normalizeName(query)
database.readableDatabase.query( val selection = mutableListOf<String>()
"exercises", val arguments = mutableListOf<String>()
arrayOf( if (normalizedPrefix.isNotEmpty()) {
"exercise_id", selection += "e.normalized_name LIKE ? ESCAPE '\\'"
"name", arguments += normalizedPrefix
"normalized_name", .replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
"recording_mode", }
"tracking_mode", if (unclassifiedOnly) {
"data_fields", selection += "NOT EXISTS(SELECT 1 FROM exercise_body_zones missing WHERE missing.exercise_row_id=e.id)"
), } else if (zoneId != null) {
null, val accepted = if (includeDescendants) bodyZones.descendantsAndSelf(zoneId) else setOf(zoneId)
null, check(bodyZones.lookup(zoneId) != null) { "zone_id inconnu: $zoneId" }
null, val placeholders = accepted.joinToString(",") { "?" }
null, selection += "EXISTS(SELECT 1 FROM exercise_body_zones selected WHERE " +
"name COLLATE NOCASE, exercise_id", "selected.exercise_row_id=e.id AND selected.zone_id IN($placeholders)" +
).use { cursor -> (if (primaryOnly) " AND selected.role='primary'" else "") + ")"
arguments += accepted
}
val sql = "SELECT e.exercise_id,e.name,e.normalized_name,e.recording_mode," +
"e.tracking_mode,e.data_fields FROM exercises e" +
(if (selection.isEmpty()) "" else " WHERE " + selection.joinToString(" AND ")) +
" ORDER BY e.name COLLATE NOCASE,e.exercise_id;"
val db = database.readableDatabase
db.rawQuery(sql, arguments.toTypedArray()).use { cursor ->
val idIndex = val idIndex =
cursor.getColumnIndexOrThrow( cursor.getColumnIndexOrThrow(
"exercise_id" "exercise_id"
@ -293,12 +341,12 @@ class TrainlogRepository(
) )
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val exerciseId = cursor.getString(idIndex)
val selectionForExercise = readExerciseBodyZones(db, exerciseId)
output += output +=
ExerciseProfile( ExerciseProfile(
exerciseId = exerciseId =
cursor.getString( exerciseId,
idIndex
),
name = name =
cursor.getString( cursor.getString(
nameIndex nameIndex
@ -335,6 +383,8 @@ class TrainlogRepository(
cursor.getInt( cursor.getInt(
fieldsIndex fieldsIndex
), ),
primaryZoneId = selectionForExercise.first,
secondaryZoneIds = selectionForExercise.second,
) )
} }
} }
@ -345,7 +395,8 @@ class TrainlogRepository(
fun createExercise( fun createExercise(
input: NewExerciseProfile, input: NewExerciseProfile,
): CreateExerciseResult { ): CreateExerciseResult {
if (!input.validate()) { if (!input.validate() || !bodyZoneSelectionValid(
input.primaryZoneId, input.secondaryZoneIds)) {
return CreateExerciseResult.Invalid return CreateExerciseResult.Invalid
} }
@ -374,6 +425,10 @@ class TrainlogRepository(
input.trackingMode, input.trackingMode,
dataFields = dataFields =
input.dataFields, input.dataFields,
primaryZoneId = input.primaryZoneId,
secondaryZoneIds = input.secondaryZoneIds.sortedBy { id ->
bodyZones.lookup(id)?.sortOrder ?: Int.MAX_VALUE
},
) )
val sql = val sql =
@ -388,8 +443,10 @@ class TrainlogRepository(
) VALUES(?, ?, ?, ?, ?, ?); ) VALUES(?, ?, ?, ?, ?, ?);
""".trimIndent() """.trimIndent()
val db = database.writableDatabase
return try { return try {
database.writableDatabase.execSQL( db.beginTransaction()
db.execSQL(
sql, sql,
arrayOf<Any?>( arrayOf<Any?>(
exercise.exerciseId, exercise.exerciseId,
@ -403,6 +460,9 @@ class TrainlogRepository(
), ),
) )
replaceExerciseBodyZones(db, exercise.exerciseId,
exercise.primaryZoneId, exercise.secondaryZoneIds)
db.setTransactionSuccessful()
CreateExerciseResult.Created( CreateExerciseResult.Created(
exercise exercise
) )
@ -410,6 +470,12 @@ class TrainlogRepository(
error: SQLiteConstraintException error: SQLiteConstraintException
) { ) {
CreateExerciseResult.Conflict CreateExerciseResult.Conflict
} catch (error: Exception) {
CreateExerciseResult.DatabaseError(
error.message ?: "Enregistrement de l'exercice impossible.",
)
} finally {
if (db.inTransaction()) db.endTransaction()
} }
} }
@ -430,7 +496,8 @@ class TrainlogRepository(
fun editExercise( fun editExercise(
input: ExerciseEditInput, input: ExerciseEditInput,
): EditExerciseResult { ): EditExerciseResult {
if (!input.validateProfile()) { if (!input.validateProfile() || !bodyZoneSelectionValid(
input.primaryZoneId, input.secondaryZoneIds)) {
return EditExerciseResult.InvalidNameOrProfile return EditExerciseResult.InvalidNameOrProfile
} }
@ -468,6 +535,8 @@ class TrainlogRepository(
if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) { if (db.update("exercises", values, "id = ?", arrayOf(current.rowId.toString())) != 1) {
return EditExerciseResult.DatabaseError return EditExerciseResult.DatabaseError
} }
replaceExerciseBodyZones(db, input.exerciseId,
input.primaryZoneId, input.secondaryZoneIds)
db.setTransactionSuccessful() db.setTransactionSuccessful()
EditExerciseResult.Saved( EditExerciseResult.Saved(
ExerciseProfile( ExerciseProfile(
@ -477,6 +546,10 @@ class TrainlogRepository(
recordingMode = input.recordingMode, recordingMode = input.recordingMode,
trackingMode = input.trackingMode, trackingMode = input.trackingMode,
dataFields = input.dataFields, dataFields = input.dataFields,
primaryZoneId = input.primaryZoneId,
secondaryZoneIds = input.secondaryZoneIds.sortedBy { id ->
bodyZones.lookup(id)?.sortOrder ?: Int.MAX_VALUE
},
), ),
) )
} catch (error: SQLiteConstraintException) { } catch (error: SQLiteConstraintException) {
@ -490,6 +563,132 @@ class TrainlogRepository(
} }
} }
/**
* CONTRACT: this is the sole body-zone exchange source in both directions.
* Session V2 and frozen TRAINLOG_FORMAT_V1 retain their exact shapes.
*/
fun buildExerciseBodyZonesJson(): String {
val exercises = JSONArray()
listExercises().sortedBy { it.exerciseId }.forEach { exercise ->
exercises.put(JSONObject()
.put("exercise_id", exercise.exerciseId)
.put("primary_zone_id", exercise.primaryZoneId ?: JSONObject.NULL)
.put("secondary_zone_ids", JSONArray(exercise.secondaryZoneIds.sorted())))
}
return JSONObject()
.put("format", "trainlog-exercise-body-zones")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("exercises", exercises)
.toString()
}
fun applyExerciseBodyZonesJson(json: String): ExerciseBodyZoneImportResult {
val root = try { JSONObject(json) } catch (_: Exception) {
return ExerciseBodyZoneImportResult.Invalid("Relations de zones JSON invalides.")
}
val rootKeys = setOf("format", "version", "generated_at", "exercises")
if (!root.hasExactKeys(rootKeys) ||
root.value("format") != "trainlog-exercise-body-zones" ||
!root.value("version").isJsonInt(1, 1) ||
!root.value("generated_at").isNonemptyJsonString() ||
root.value("exercises") !is JSONArray) {
return ExerciseBodyZoneImportResult.Invalid("Relations de zones v1 non supportées.")
}
try {
OffsetDateTime.parse(root.getString("generated_at"))
} catch (_: Exception) {
return ExerciseBodyZoneImportResult.Invalid("Horodatage des zones invalide.")
}
data class Incoming(val exerciseId: String, val primary: String?, val secondary: List<String>)
val incoming = mutableListOf<Incoming>()
val seenExercises = mutableSetOf<String>()
try {
val array = root.getJSONArray("exercises")
for (index in 0 until array.length()) {
val item = array.opt(index) as? JSONObject
?: return ExerciseBodyZoneImportResult.Invalid("Relation de zone invalide.")
if (!item.hasExactKeys(setOf("exercise_id", "primary_zone_id", "secondary_zone_ids")))
return ExerciseBodyZoneImportResult.Invalid("Forme de relation de zone invalide.")
val exerciseId = item.value("exercise_id")
val primaryValue = item.value("primary_zone_id")
val secondaryValue = item.value("secondary_zone_ids")
if (!exerciseId.isNonemptyJsonString() ||
!(exerciseId as String).matches(EXERCISE_ID_V4_PATTERN) ||
!seenExercises.add(exerciseId) ||
!(primaryValue === JSONObject.NULL || primaryValue.isNonemptyJsonString()) ||
secondaryValue !is JSONArray) {
return ExerciseBodyZoneImportResult.Invalid("Identité de relation de zone invalide.")
}
val secondary = List(secondaryValue.length()) { position ->
secondaryValue.opt(position) as? String
?: return ExerciseBodyZoneImportResult.Invalid("zone_id secondaire invalide.")
}
val primary = if (primaryValue === JSONObject.NULL) null else primaryValue as String
if (!bodyZoneSelectionValid(primary, secondary))
return ExerciseBodyZoneImportResult.Invalid("Sélection de zones invalide : $exerciseId")
incoming += Incoming(exerciseId, primary, secondary.sorted())
}
} catch (_: Exception) {
return ExerciseBodyZoneImportResult.Invalid("Relations de zones invalides.")
}
val db = database.writableDatabase
var updated = 0
var skipped = 0
var keptLocal = 0
return try {
db.beginTransaction()
incoming.forEach { item ->
val rowId = lookupExerciseRowIdOrNull(db, item.exerciseId)
?: return ExerciseBodyZoneImportResult.Invalid(
"Exercice de relation inconnu : ${item.exerciseId}",
)
val local = readExerciseBodyZones(db, item.exerciseId)
val localState = bodyZoneSyncState(local.first, local.second)
val incomingState = bodyZoneSyncState(item.primary, item.secondary)
val baseline = db.rawQuery(
"SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?;",
arrayOf(rowId.toString()),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
when {
localState == incomingState -> {
writeBodyZoneSyncBaseline(db, rowId, incomingState)
skipped += 1
}
baseline != null && localState == baseline -> {
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
writeBodyZoneSyncBaseline(db, rowId, incomingState)
updated += 1
}
baseline != null && incomingState == baseline -> keptLocal += 1
baseline == null && localState == "|" -> {
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
writeBodyZoneSyncBaseline(db, rowId, incomingState)
updated += 1
}
else -> return ExerciseBodyZoneImportResult.Conflict(item.exerciseId)
}
}
db.setTransactionSuccessful()
ExerciseBodyZoneImportResult.Applied(updated, skipped, keptLocal)
} catch (_: Exception) {
ExerciseBodyZoneImportResult.DatabaseError
} finally {
if (db.inTransaction()) db.endTransaction()
}
}
private fun bodyZoneSyncState(primary: String?, secondary: List<String>): String =
(primary ?: "") + "|" + secondary.sorted().joinToString(",")
private fun writeBodyZoneSyncBaseline(db: SQLiteDatabase, rowId: Long, state: String) {
db.execSQL(
"INSERT OR REPLACE INTO exercise_body_zone_sync(exercise_row_id,synced_state) VALUES(?,?);",
arrayOf<Any>(rowId, state),
)
}
fun loadActiveSessionDraft(): fun loadActiveSessionDraft():
ActiveDraftLoadResult = ActiveDraftLoadResult =
try { try {
@ -1229,6 +1428,11 @@ class TrainlogRepository(
db, db,
retired.exerciseId, retired.exerciseId,
byId.exerciseId, byId.exerciseId,
) ||
!bodyZoneRowsAreCompatibleForMerge(
db,
byId.exerciseId,
retired.exerciseId,
) )
) { ) {
traceDecision( traceDecision(
@ -1487,6 +1691,7 @@ class TrainlogRepository(
canonical: ExerciseRow, canonical: ExerciseRow,
retired: ExerciseRow, retired: ExerciseRow,
) { ) {
mergeExerciseBodyZoneRows(db, canonical, retired)
db.execSQL( db.execSQL(
"UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId), arrayOf(canonical.rowId, retired.rowId),
@ -1520,6 +1725,64 @@ class TrainlogRepository(
) )
} }
private fun sqliteTableExists(db: SQLiteDatabase, table: String): Boolean =
db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;",
arrayOf(table),
).use { it.moveToFirst() }
private fun bodyZoneRowsAreCompatibleForMerge(
db: SQLiteDatabase,
canonicalExerciseId: String,
retiredExerciseId: String,
): Boolean {
if (!sqliteTableExists(db, "exercise_body_zones")) return true
val canonical = readExerciseBodyZones(db, canonicalExerciseId)
val retired = readExerciseBodyZones(db, retiredExerciseId)
val canonicalEmpty = canonical.first == null && canonical.second.isEmpty()
val retiredEmpty = retired.first == null && retired.second.isEmpty()
return canonicalEmpty || retiredEmpty || canonical == retired
}
private fun mergeExerciseBodyZoneRows(
db: SQLiteDatabase,
canonical: ExerciseRow,
retired: ExerciseRow,
) {
if (!sqliteTableExists(db, "exercise_body_zones")) return
val canonicalState = readExerciseBodyZones(db, canonical.exerciseId)
val retiredState = readExerciseBodyZones(db, retired.exerciseId)
val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty()
val retiredEmpty = retiredState.first == null && retiredState.second.isEmpty()
check(canonicalEmpty || retiredEmpty || canonicalState == retiredState) {
"Relations de zones incompatibles pendant la réconciliation d'identité."
}
/* WHY: row identity reconciliation must not discard the only body-zone
* decision. CONTRACT: equal states coalesce, one empty side adopts the
* non-empty state, and differing non-empty states were rejected above.
* INVARIANT: baselines are cleared because an identity merge is not a
* synchronization acknowledgement; the next companion must establish
* a fresh common ancestor before accepting a one-sided change. */
if (canonicalEmpty && !retiredEmpty) {
db.execSQL(
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, retired.rowId),
)
} else {
db.execSQL(
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
arrayOf(retired.rowId),
)
}
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
db.execSQL(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
arrayOf(canonical.rowId, retired.rowId),
)
}
}
private fun findExerciseRow( private fun findExerciseRow(
db: SQLiteDatabase, db: SQLiteDatabase,
selection: String, selection: String,
@ -3710,6 +3973,79 @@ class TrainlogRepository(
} }
} }
private fun bodyZoneSelectionValid(
primaryZoneId: String?,
secondaryZoneIds: List<String>,
): Boolean {
if (secondaryZoneIds.size != secondaryZoneIds.toSet().size ||
primaryZoneId in secondaryZoneIds ||
(primaryZoneId == null && secondaryZoneIds.isNotEmpty())) return false
val ids = listOfNotNull(primaryZoneId) + secondaryZoneIds
return ids.all { id ->
val zone = bodyZones.lookup(id)
zone != null && zone.kind != BodyZoneKind.GROUP
}
}
/** INVARIANT: the first component is the sole primary relation; the
* secondary list follows manifest sort order for deterministic UI/export. */
private fun readExerciseBodyZones(
db: SQLiteDatabase,
exerciseId: String,
): Pair<String?, List<String>> {
var primary: String? = null
val secondary = mutableListOf<String>()
db.rawQuery(
"SELECT ebz.zone_id,ebz.role FROM exercise_body_zones ebz " +
"JOIN exercises e ON e.id=ebz.exercise_row_id WHERE e.exercise_id=?;",
arrayOf(exerciseId),
).use { cursor ->
while (cursor.moveToNext()) {
val zoneId = cursor.getString(0)
val zone = checkNotNull(bodyZones.lookup(zoneId)) {
"zone_id SQLite inconnu: $zoneId"
}
check(zone.kind != BodyZoneKind.GROUP) {
"Relation SQLite vers un groupe dérivable: $zoneId"
}
when (cursor.getString(1)) {
"primary" -> {
check(primary == null) { "Plusieurs zones principales" }
primary = zoneId
}
"secondary" -> secondary += zoneId
else -> error("Rôle de zone SQLite inconnu")
}
}
}
check(primary != null || secondary.isEmpty()) {
"Relations secondaires sans zone principale"
}
return primary to secondary.sortedBy { bodyZones.lookup(it)?.sortOrder ?: Int.MAX_VALUE }
}
private fun replaceExerciseBodyZones(
db: SQLiteDatabase,
exerciseId: String,
primaryZoneId: String?,
secondaryZoneIds: List<String>,
) {
check(bodyZoneSelectionValid(primaryZoneId, secondaryZoneIds)) {
"Sélection de zones corporelles invalide"
}
val rowId = lookupExerciseRowId(db, exerciseId)
db.delete("exercise_body_zones", "exercise_row_id=?", arrayOf(rowId.toString()))
fun insert(zoneId: String, role: String) {
db.insertOrThrow("exercise_body_zones", null, ContentValues().apply {
put("exercise_row_id", rowId)
put("zone_id", zoneId)
put("role", role)
})
}
primaryZoneId?.let { insert(it, "primary") }
secondaryZoneIds.forEach { insert(it, "secondary") }
}
private fun normalizeName( private fun normalizeName(
value: String, value: String,
): String { ): String {
@ -3771,6 +4107,9 @@ private const val ANDROID_LEG_PRESS_LEGACY_ID =
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29" "ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
private const val DESKTOP_LEG_PRESS_CANONICAL_ID = private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
private val EXERCISE_ID_V4_PATTERN = Regex(
"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
private class TrainlogDatabaseHelper( private class TrainlogDatabaseHelper(
private val appContext: Context, private val appContext: Context,
@ -3779,7 +4118,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
9, 10,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -3806,6 +4145,7 @@ private class TrainlogDatabaseHelper(
createBodyTable(db) createBodyTable(db)
createActiveDraftTables(db) createActiveDraftTables(db)
createEquipmentTables(db) createEquipmentTables(db)
createBodyZoneTables(db)
seedEquipment(db) seedEquipment(db)
} }
@ -3882,6 +4222,15 @@ private class TrainlogDatabaseHelper(
version = 9 version = 9
} }
if (version < 10 && newVersion >= 10) {
/* WHY: the approved legacy Leg press identity must be canonical
* before identity-keyed manifest mappings are applied. */
migrateApprovedLegPressIdentity(db)
createBodyZoneTables(db)
seedInitialBodyZones(db)
version = 10
}
if (version != newVersion) { if (version != newVersion) {
error( error(
"Unsupported Android DB upgrade " + "Unsupported Android DB upgrade " +
@ -3890,6 +4239,55 @@ private class TrainlogDatabaseHelper(
} }
} }
private fun createBodyZoneTables(db: SQLiteDatabase) {
/* CONTRACT: canonical zone definitions remain in the asset; SQLite
* stores only direct exercise relations and role cardinality. */
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_body_zones(" +
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE," +
"zone_id TEXT NOT NULL," +
"role TEXT NOT NULL CHECK(role IN('primary','secondary'))," +
"PRIMARY KEY(exercise_row_id,zone_id));",
)
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS exercise_body_zones_one_primary " +
"ON exercise_body_zones(exercise_row_id) WHERE role='primary';",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS exercise_body_zone_sync(" +
"exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE," +
"synced_state TEXT NOT NULL);",
)
}
private fun seedInitialBodyZones(db: SQLiteDatabase) {
val catalog = BodyZoneCatalog.load(appContext)
catalog.initialMappings.forEach { mapping ->
val rowId = db.rawQuery(
"SELECT id FROM exercises WHERE exercise_id=?;",
arrayOf(mapping.exerciseId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
?: return@forEach
fun insert(zoneId: String, role: String) {
db.insertOrThrow("exercise_body_zones", null, ContentValues().apply {
put("exercise_row_id", rowId)
put("zone_id", zoneId)
put("role", role)
})
}
insert(mapping.primaryZoneId, "primary")
mapping.secondaryZoneIds.forEach { insert(it, "secondary") }
}
db.execSQL(
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) " +
"SELECT e.id,COALESCE((SELECT p.zone_id FROM exercise_body_zones p " +
"WHERE p.exercise_row_id=e.id AND p.role='primary'),'')||'|'||" +
"COALESCE((SELECT group_concat(s.zone_id,',') FROM " +
"(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=e.id " +
"AND role='secondary' ORDER BY zone_id) s),'') FROM exercises e;",
)
}
private data class ExerciseIdentityRow( private data class ExerciseIdentityRow(
val rowId: Long, val rowId: Long,
val exerciseId: String, val exerciseId: String,
@ -3956,7 +4354,8 @@ private class TrainlogDatabaseHelper(
} }
} }
db.beginTransaction() val ownsTransaction = !db.inTransaction()
if (ownsTransaction) db.beginTransaction()
try { try {
if (canonical == null) { if (canonical == null) {
/* Keep the legacy exercise row itself: all foreign-key graph /* Keep the legacy exercise row itself: all foreign-key graph
@ -3982,6 +4381,8 @@ private class TrainlogDatabaseHelper(
"Métadonnées équipement Leg press incompatibles; migration refusée." "Métadonnées équipement Leg press incompatibles; migration refusée."
} }
mergeApprovedIdentityBodyZones(db, canonical.rowId, legacy.rowId)
db.execSQL( db.execSQL(
"UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonical.rowId, legacy.rowId), arrayOf(canonical.rowId, legacy.rowId),
@ -4018,9 +4419,62 @@ private class TrainlogDatabaseHelper(
check(exerciseIdentityRow(db, DESKTOP_LEG_PRESS_CANONICAL_ID) != null) { check(exerciseIdentityRow(db, DESKTOP_LEG_PRESS_CANONICAL_ID) != null) {
"Identité Leg press desktop absente après migration." "Identité Leg press desktop absente après migration."
} }
db.setTransactionSuccessful() if (ownsTransaction) db.setTransactionSuccessful()
} finally { } finally {
db.endTransaction() if (ownsTransaction) db.endTransaction()
}
}
private fun mergeApprovedIdentityBodyZones(
db: SQLiteDatabase,
canonicalRowId: Long,
legacyRowId: Long,
) {
val hasRelations = db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type='table' " +
"AND name='exercise_body_zones';",
null,
).use { it.moveToFirst() }
if (!hasRelations) return
fun state(rowId: Long): List<Pair<String, String>> = db.rawQuery(
"SELECT zone_id,role FROM exercise_body_zones " +
"WHERE exercise_row_id=? ORDER BY role,zone_id;",
arrayOf(rowId.toString()),
).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.getString(0) to cursor.getString(1))
}
}
val canonicalState = state(canonicalRowId)
val legacyState = state(legacyRowId)
check(canonicalState.isEmpty() || legacyState.isEmpty() || canonicalState == legacyState) {
"Relations de zones Leg press incompatibles; migration refusée."
}
if (canonicalState.isEmpty() && legacyState.isNotEmpty()) {
db.execSQL(
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
arrayOf(canonicalRowId, legacyRowId),
)
} else {
db.execSQL(
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
arrayOf(legacyRowId),
)
}
/* INVARIANT: identity repair is not a sync acknowledgement. A fresh
* shared baseline must be established by an identical companion. */
val hasBaseline = db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type='table' " +
"AND name='exercise_body_zone_sync';",
null,
).use { it.moveToFirst() }
if (hasBaseline) {
db.execSQL(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
arrayOf(canonicalRowId, legacyRowId),
)
} }
} }

View file

@ -29,6 +29,9 @@ data class ExerciseProfile(
val recordingMode: RecordingMode, val recordingMode: RecordingMode,
val trackingMode: TrackingMode, val trackingMode: TrackingMode,
val dataFields: Int, val dataFields: Int,
/** Stable manifest IDs; display names are presentation-only metadata. */
val primaryZoneId: String? = null,
val secondaryZoneIds: List<String> = emptyList(),
) )
data class NewExerciseProfile( data class NewExerciseProfile(
@ -36,6 +39,9 @@ data class NewExerciseProfile(
val recordingMode: RecordingMode, val recordingMode: RecordingMode,
val trackingMode: TrackingMode, val trackingMode: TrackingMode,
val dataFields: Int, val dataFields: Int,
/** Null is the explicit no-relations state; validation rejects groups and orphan secondaries. */
val primaryZoneId: String? = null,
val secondaryZoneIds: List<String> = emptyList(),
) { ) {
fun validate(): Boolean { fun validate(): Boolean {
if (name.isBlank()) { if (name.isBlank()) {
@ -74,7 +80,8 @@ data class NewExerciseProfile(
/** /**
* CONTRACT: an edit addresses the existing stable identity. `name` is * CONTRACT: an edit addresses the existing stable identity. `name` is
* presentation metadata, not a replacement identity, so callers must never * presentation metadata, not a replacement identity, so callers must never
* create a second exercise merely to rename one. * create a second exercise merely to rename one. Name/profile/zones are saved
* transactionally; cancelling before this call has no persistence effect.
*/ */
data class ExerciseEditInput( data class ExerciseEditInput(
val exerciseId: String, val exerciseId: String,
@ -82,6 +89,8 @@ data class ExerciseEditInput(
val recordingMode: RecordingMode, val recordingMode: RecordingMode,
val trackingMode: TrackingMode, val trackingMode: TrackingMode,
val dataFields: Int, val dataFields: Int,
val primaryZoneId: String? = null,
val secondaryZoneIds: List<String> = emptyList(),
) { ) {
fun validateProfile(): Boolean = fun validateProfile(): Boolean =
NewExerciseProfile( NewExerciseProfile(

View file

@ -16,6 +16,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.CreateExerciseResult import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.BodyZone
import com.labfytools.trainlog.data.BodyZoneKind
import com.labfytools.trainlog.data.EditExerciseResult import com.labfytools.trainlog.data.EditExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
@ -66,6 +68,13 @@ fun ExerciseScreen(
mutableStateOf(false) mutableStateOf(false)
} }
var primaryZoneId by remember { mutableStateOf<String?>(null) }
var secondaryZoneIds by remember { mutableStateOf(emptySet<String>()) }
var searchQuery by remember { mutableStateOf("") }
var filterZoneId by remember { mutableStateOf<String?>(null) }
var unclassifiedFilter by remember { mutableStateOf(false) }
val zones = repository.listBodyZones()
var message by var message by
remember { remember {
mutableStateOf<String?>( mutableStateOf<String?>(
@ -92,6 +101,8 @@ fun ExerciseScreen(
trackingMode = exercise.trackingMode trackingMode = exercise.trackingMode
speed = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0 speed = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0
distance = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0 distance = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0
primaryZoneId = exercise.primaryZoneId
secondaryZoneIds = exercise.secondaryZoneIds.toSet()
message = null message = null
} }
@ -245,6 +256,49 @@ fun ExerciseScreen(
} }
} }
TrainlogChoiceGroup(label = "Zone principale") {
TrainlogChoice(
label = "Non renseignée",
selected = primaryZoneId == null,
onClick = {
primaryZoneId = null
secondaryZoneIds = emptySet()
message = null
},
)
BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice(
label = (if (indented) "" else "") + zone.displayName,
selected = primaryZoneId == zone.zoneId,
enabled = zone.kind != BodyZoneKind.GROUP,
onClick = {
primaryZoneId = zone.zoneId
secondaryZoneIds = secondaryZoneIds - zone.zoneId
message = null
},
)
}
}
TrainlogChoiceGroup(label = "Zones secondaires") {
BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice(
label = (if (indented) "" else "") + zone.displayName,
selected = zone.zoneId in secondaryZoneIds,
enabled = primaryZoneId != null &&
zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId,
onClick = {
secondaryZoneIds = if (zone.zoneId in secondaryZoneIds) {
secondaryZoneIds - zone.zoneId
} else {
secondaryZoneIds + zone.zoneId
}
message = null
},
)
}
}
val fields = val fields =
if ( if (
recordingMode == recordingMode ==
@ -309,7 +363,12 @@ fun ExerciseScreen(
}, },
accent = accent =
colors.success, colors.success,
onClick = { onClick = save@{
if (editedExercise == null &&
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
message = "Une zone principale est requise pour un nouvel exercice musculaire."
return@save
}
val current = editedExercise val current = editedExercise
val result = val result =
if (current == null) { if (current == null) {
@ -322,6 +381,8 @@ fun ExerciseScreen(
trackingMode, trackingMode,
dataFields = dataFields =
fields, fields,
primaryZoneId = primaryZoneId,
secondaryZoneIds = secondaryZoneIds.toList(),
), ),
) )
} else { } else {
@ -332,6 +393,8 @@ fun ExerciseScreen(
recordingMode = recordingMode, recordingMode = recordingMode,
trackingMode = trackingMode, trackingMode = trackingMode,
dataFields = fields, dataFields = fields,
primaryZoneId = primaryZoneId,
secondaryZoneIds = secondaryZoneIds.toList(),
), ),
) )
} }
@ -351,6 +414,10 @@ fun ExerciseScreen(
"Profil ou nom invalide." "Profil ou nom invalide."
} }
is CreateExerciseResult.DatabaseError -> {
message = result.message
}
is EditExerciseResult.Saved -> { is EditExerciseResult.Saved -> {
message = null message = null
editedExercise = null editedExercise = null
@ -389,6 +456,8 @@ fun ExerciseScreen(
trackingMode = TrackingMode.REPS trackingMode = TrackingMode.REPS
speed = false speed = false
distance = false distance = false
primaryZoneId = null
secondaryZoneIds = emptySet()
message = null message = null
}, },
) )
@ -404,14 +473,43 @@ fun ExerciseScreen(
} }
TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) { TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) {
val exercises = repository.listExercises() TrainlogInputField(
label = "Recherche par préfixe",
value = searchQuery,
onValueChange = { searchQuery = it },
)
TrainlogChoiceGroup(label = "Filtre par zone") {
TrainlogChoice(
label = "Toutes les zones",
selected = filterZoneId == null && !unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = false },
)
BodyZoneChoices(zones, includeGroups = true) { zone, indented ->
TrainlogChoice(
label = (if (indented) "" else "") + zone.displayName,
selected = filterZoneId == zone.zoneId && !unclassifiedFilter,
onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false },
)
}
TrainlogChoice(
label = "Non renseignés",
selected = unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = true },
)
}
val exercises = repository.listExercises(
query = searchQuery,
zoneId = filterZoneId,
includeDescendants = true,
unclassifiedOnly = unclassifiedFilter,
)
if (exercises.isEmpty()) { if (exercises.isEmpty()) {
TrainlogInfo("Aucun exercice enregistré.") TrainlogInfo("Aucun exercice enregistré.")
} else { } else {
exercises.forEach { exercise -> exercises.forEach { exercise ->
TrainlogAction( TrainlogAction(
label = "Modifier · ${exercise.name}", label = "Modifier · ${exercise.name}",
description = "Modifier le nom ou le profil si disponible.", description = exerciseZoneSummary(repository, exercise),
accent = colors.accent, accent = colors.accent,
onClick = { startEditing(exercise) }, onClick = { startEditing(exercise) },
) )
@ -434,6 +532,37 @@ fun ExerciseScreen(
} }
} }
@Composable
private fun BodyZoneChoices(
zones: List<BodyZone>,
includeGroups: Boolean = false,
content: @Composable (BodyZone, Boolean) -> Unit,
) {
zones.forEach { zone ->
if (includeGroups || zone.kind != BodyZoneKind.GROUP) {
content(zone, zone.parentZoneId != null)
} else {
TrainlogInfo(zone.displayName)
}
}
}
private fun exerciseZoneSummary(
repository: TrainlogRepository,
exercise: ExerciseProfile,
): String {
val primary = exercise.primaryZoneId?.let(repository::bodyZone)
?: return "Zone : Non renseignée"
val secondary = exercise.secondaryZoneIds.mapNotNull(repository::bodyZone)
val group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull()
return buildString {
append("Zone principale : ${primary.displayName}")
append(" · Zones secondaires : ")
append(if (secondary.isEmpty()) "Aucune" else secondary.joinToString { it.displayName })
group?.let { append(" · Groupe : ${it.displayName}") }
}
}
@Composable @Composable
private fun TrainlogField( private fun TrainlogField(
label: String, label: String,

View file

@ -0,0 +1,355 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BodyZonesTest {
private lateinit var context: Context
private val databases = mutableListOf<String>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@After
fun tearDown() {
databases.forEach(context::deleteDatabase)
}
private fun repository(name: String): TrainlogRepository {
databases += name
context.deleteDatabase(name)
return TrainlogRepository(context, name)
}
@Test
fun canonicalTaxonomyIsUniqueAcyclicAndHasDeclaredGroups() {
val catalog = BodyZoneCatalog.load(context)
assertEquals(11, catalog.zones.size)
assertEquals(catalog.zones.size, catalog.zones.map { it.zoneId }.toSet().size)
assertEquals(catalog.zones.size, catalog.zones.map { it.sortOrder }.toSet().size)
assertEquals(null, catalog.lookup("full_body")!!.parentZoneId)
assertEquals(BodyZoneKind.GROUP, catalog.lookup("upper_body")!!.kind)
assertEquals(setOf("chest", "back", "shoulders", "arms"),
catalog.descendantsAndSelf("upper_body") - "upper_body")
assertEquals(setOf("glutes", "thighs", "calves"),
catalog.descendantsAndSelf("lower_body") - "lower_body")
catalog.zones.forEach { zone ->
assertTrue(catalog.ancestors(zone.zoneId).map { it.zoneId }.distinct().size <= 1)
}
}
@Test
fun createEditFilterUnclassifiedAndReopenPreserveRelations() {
val name = "body-zones-model.db"
val first = repository(name)
val created = first.createExercise(NewExerciseProfile(
name = "Chest custom", recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS, dataFields = 0,
primaryZoneId = "chest", secondaryZoneIds = listOf("shoulders", "arms"),
)) as CreateExerciseResult.Created
assertEquals("chest", created.exercise.primaryZoneId)
assertEquals(listOf(created.exercise.exerciseId),
first.listExercises(zoneId = "upper_body").map { it.exerciseId })
assertEquals(listOf(created.exercise.exerciseId),
first.listExercises(query = "che", zoneId = "chest").map { it.exerciseId })
assertTrue(first.listExercises(query = "lat", zoneId = "back").isEmpty())
assertTrue(first.createExercise(NewExerciseProfile(
"Invalid", RecordingMode.SETS, TrackingMode.REPS, 0,
primaryZoneId = "chest", secondaryZoneIds = listOf("chest"),
)) is CreateExerciseResult.Invalid)
assertTrue(first.createExercise(NewExerciseProfile(
"Unknown", RecordingMode.SETS, TrackingMode.REPS, 0,
primaryZoneId = "unknown", secondaryZoneIds = emptyList(),
)) is CreateExerciseResult.Invalid)
assertTrue(first.createExercise(NewExerciseProfile(
"Derived group", RecordingMode.SETS, TrackingMode.REPS, 0,
primaryZoneId = "upper_body", secondaryZoneIds = emptyList(),
)) is CreateExerciseResult.Invalid)
assertTrue(first.createExercise(NewExerciseProfile(
"Orphan secondary", RecordingMode.SETS, TrackingMode.REPS, 0,
primaryZoneId = null, secondaryZoneIds = listOf("arms"),
)) is CreateExerciseResult.Invalid)
listOf("glutes", "thighs", "calves").forEach { zoneId ->
assertTrue(first.createExercise(NewExerciseProfile(
"Lower $zoneId", RecordingMode.SETS, TrackingMode.REPS, 0,
primaryZoneId = zoneId, secondaryZoneIds = emptyList(),
)) is CreateExerciseResult.Created)
}
assertEquals(3, first.listExercises(zoneId = "lower_body").size)
val edited = first.editExercise(ExerciseEditInput(
created.exercise.exerciseId, "Chest custom", RecordingMode.SETS,
TrackingMode.REPS, 0, "back", listOf("arms"),
))
assertTrue(edited is EditExerciseResult.Saved)
assertTrue(first.listExercises(zoneId = "chest").isEmpty())
assertEquals(1, first.listExercises(zoneId = "back").size)
val unclassified = first.createExercise(NewExerciseProfile(
"Marche custom", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0,
)) as CreateExerciseResult.Created
assertEquals(listOf(unclassified.exercise.exerciseId),
first.listExercises(unclassifiedOnly = true).map { it.exerciseId })
var ambiguousFilterRejected = false
try {
first.listExercises(zoneId = "back", unclassifiedOnly = true)
} catch (_: IllegalArgumentException) {
ambiguousFilterRejected = true
}
assertTrue(ambiguousFilterRejected)
first.close()
val reopened = TrainlogRepository(context, name)
val restored = reopened.listExercises().associateBy { it.exerciseId }
assertEquals("back", restored.getValue(created.exercise.exerciseId).primaryZoneId)
assertEquals(listOf("arms"), restored.getValue(created.exercise.exerciseId).secondaryZoneIds)
reopened.close()
}
@Test
fun companionReplaysUpdatesOneSideAndRejectsSimultaneousDivergence() {
val first = repository("body-zones-sync-a.db")
val created = first.createExercise(NewExerciseProfile(
"Sync custom", RecordingMode.SETS, TrackingMode.REPS, 0,
"chest", listOf("arms"),
)) as CreateExerciseResult.Created
val catalog = JSONObject()
.put("format", "trainlog-pc-catalog").put("version", 1)
.put("generated_at", "2032-01-01T00:00:00+00:00")
.put("exercises", JSONArray().put(JSONObject()
.put("exercise_id", created.exercise.exerciseId)
.put("name", created.exercise.name)
.put("recording_mode", "sets").put("tracking_mode", "reps")
.put("data_fields", 0)))
val second = repository("body-zones-sync-b.db")
assertTrue(second.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied)
val initial = first.buildExerciseBodyZonesJson()
// The creator acknowledges only the exact snapshot that publication
// made visible; this establishes the missing baseline for a custom row.
assertTrue(first.applyExerciseBodyZonesJson(initial) is ExerciseBodyZoneImportResult.Applied)
assertEquals(1, (second.applyExerciseBodyZonesJson(initial) as
ExerciseBodyZoneImportResult.Applied).updated)
assertTrue(second.applyExerciseBodyZonesJson(initial) is ExerciseBodyZoneImportResult.Applied)
assertTrue(second.editExercise(ExerciseEditInput(
created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS,
TrackingMode.REPS, 0, "shoulders", listOf("arms"),
)) is EditExerciseResult.Saved)
val returned = second.buildExerciseBodyZonesJson()
assertTrue(second.applyExerciseBodyZonesJson(returned) is ExerciseBodyZoneImportResult.Applied)
assertEquals(1, (first.applyExerciseBodyZonesJson(returned) as
ExerciseBodyZoneImportResult.Applied).updated)
assertEquals("shoulders", first.listExercises().single().primaryZoneId)
assertTrue(first.editExercise(ExerciseEditInput(
created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS,
TrackingMode.REPS, 0, "chest", listOf("arms"),
)) is EditExerciseResult.Saved)
assertTrue(second.editExercise(ExerciseEditInput(
created.exercise.exerciseId, created.exercise.name, RecordingMode.SETS,
TrackingMode.REPS, 0, "back", listOf("arms"),
)) is EditExerciseResult.Saved)
val divergent = first.buildExerciseBodyZonesJson()
assertTrue(first.applyExerciseBodyZonesJson(divergent) is ExerciseBodyZoneImportResult.Applied)
assertTrue(second.applyExerciseBodyZonesJson(divergent) is
ExerciseBodyZoneImportResult.Conflict)
assertEquals("back", second.listExercises().single().primaryZoneId)
first.close()
second.close()
}
@Test
fun companionRejectsInvalidStableIdentityAndTimestamp() {
val repository = repository("body-zones-invalid-companion.db")
fun artifact(exerciseId: String, generatedAt: String) = JSONObject()
.put("format", "trainlog-exercise-body-zones")
.put("version", 1)
.put("generated_at", generatedAt)
.put("exercises", JSONArray().put(JSONObject()
.put("exercise_id", exerciseId)
.put("primary_zone_id", JSONObject.NULL)
.put("secondary_zone_ids", JSONArray())))
.toString()
assertTrue(repository.applyExerciseBodyZonesJson(
artifact("ex_not-a-uuid", "2032-01-01T00:00:00+00:00"),
) is ExerciseBodyZoneImportResult.Invalid)
assertTrue(repository.applyExerciseBodyZonesJson(
artifact("ex_11111111-1111-4111-8111-111111111111", "sans-offset"),
) is ExerciseBodyZoneImportResult.Invalid)
repository.close()
}
@Test
fun catalogIdentityMergePreservesOneZoneStateAndRejectsTwoDifferentStates() {
fun pcCatalog(exerciseId: String, name: String) = JSONObject()
.put("format", "trainlog-pc-catalog").put("version", 1)
.put("generated_at", "2032-01-01T00:00:00+00:00")
.put("exercises", JSONArray().put(JSONObject()
.put("exercise_id", exerciseId).put("name", name)
.put("recording_mode", "sets").put("tracking_mode", "reps")
.put("data_fields", 0)))
.toString()
val preserving = repository("body-zones-identity-merge.db")
val canonical = preserving.createExercise(NewExerciseProfile(
"Canonical old name", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created
preserving.createExercise(NewExerciseProfile(
"Merged name", RecordingMode.SETS, TrackingMode.REPS, 0,
"chest", listOf("shoulders", "arms"),
))
assertTrue(preserving.applyPcCatalogJson(
pcCatalog(canonical.exercise.exerciseId, "Merged name"),
) is PcCatalogImportResult.Applied)
val merged = preserving.listExercises().single()
assertEquals(canonical.exercise.exerciseId, merged.exerciseId)
assertEquals("chest", merged.primaryZoneId)
assertEquals(listOf("shoulders", "arms"), merged.secondaryZoneIds)
preserving.close()
val conflicting = repository("body-zones-identity-conflict.db")
val conflictingCanonical = conflicting.createExercise(NewExerciseProfile(
"Conflicting old name", RecordingMode.SETS, TrackingMode.REPS, 0,
"chest", emptyList(),
)) as CreateExerciseResult.Created
conflicting.createExercise(NewExerciseProfile(
"Conflicting merged name", RecordingMode.SETS, TrackingMode.REPS, 0,
"back", emptyList(),
))
assertTrue(conflicting.applyPcCatalogJson(
pcCatalog(conflictingCanonical.exercise.exerciseId, "Conflicting merged name"),
) is PcCatalogImportResult.Invalid)
assertEquals(2, conflicting.listExercises().size)
conflicting.close()
}
@Test
fun versionNineFixtureMigratesKnownIdentityWithoutTouchingHistoryDraftMaxOrEquipment() {
val name = "body-zones-v9.db"
val initial = repository(name)
initial.listExercises() // Materialize the complete current schema and bundled equipment.
initial.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE exercise_body_zone_sync;")
db.execSQL("DROP TABLE exercise_body_zones;")
db.execSQL(
"INSERT INTO exercises VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," +
"'Abdominal crunch','abdominal crunch','sets','reps',0);",
)
val equipmentRow = db.rawQuery(
"SELECT id FROM equipment WHERE equipment_id='abdominal';", null,
).use { cursor -> assertTrue(cursor.moveToFirst()); cursor.getLong(0) }
db.execSQL(
"INSERT INTO sessions(id,session_id,started_at,session_type) " +
"VALUES(5,'se_zone_training','2032-01-01T08:00:00+01:00','training')," +
"(6,'se_zone_max','2032-01-02T08:00:00+01:00','max_test');",
)
db.execSQL(
"INSERT INTO session_exercises(id,session_row_id,exercise_row_id,position," +
"recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) " +
"VALUES(7,5,42,0,'sets','reps',0,?,'sxe_zone_training')," +
"(8,6,42,0,'sets','reps',0,?,'sxe_zone_max');",
arrayOf<Any>(equipmentRow, equipmentRow),
)
db.execSQL(
"INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,weight_kg) " +
"VALUES(9,7,0,12,42.5);",
)
db.execSQL("INSERT INTO max_results VALUES(8,85.0);")
db.execSQL(
"INSERT INTO body_observations(id,observation_id,observed_at,body_weight_kg,waist_cm) " +
"VALUES(12,'bo_zone_preserved','2032-01-02T09:00:00+01:00',81.5,91.0);",
)
db.execSQL(
"INSERT INTO equipment(id,equipment_id,label_name,display_name,equipment_type,load_semantics) " +
"VALUES(999,'eq_44444444-4444-4444-8444-444444444444'," +
"'Machine custom','Machine custom','strength_machine','external');",
)
db.execSQL(
"INSERT INTO active_session_draft(id,session_type,selected_exercise_row_id," +
"selected_exercise_label,selected_equipment_id,source_session_id,weight_text," +
"max_weight_text,set_count_text,reps_text,duration_text,speed_text,distance_text,updated_at) " +
"VALUES(1,'training',42,'Abdominal crunch','abdominal',NULL,'42,5','','1','10','','',''," +
"'2032-01-03T08:00:00+01:00');",
)
db.execSQL(
"INSERT INTO draft_session_exercises(id,draft_id,exercise_row_id,position," +
"recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) " +
"VALUES(10,1,42,0,'sets','reps',0,?,'sxe_zone_draft');",
arrayOf<Any>(equipmentRow),
)
db.execSQL(
"INSERT INTO draft_performed_sets(id,draft_exercise_row_id,position,reps,weight_kg) " +
"VALUES(11,10,0,10,37.5);",
)
db.execSQL("PRAGMA user_version=9;")
}
val repository = TrainlogRepository(context, name)
val exercise = repository.listExercises().single()
assertEquals("core", exercise.primaryZoneId)
val training = repository.getSessionDetail("se_zone_training")!!.exercises.single()
assertEquals("sxe_zone_training", training.entryId)
assertEquals(42.5, training.sets.single().weightKg!!, 0.0)
assertEquals("Abdominal — Crunch machine", training.equipmentDisplayName)
assertEquals(85.0, repository.listLatestExerciseMaxima().single().maxWeightKg, 0.0)
val draft = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals("sxe_zone_draft", draft.draft.exercises.single().entryId)
assertEquals(37.5, draft.draft.exercises.single().sets.single().weightKg!!, 0.0)
repository.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(10, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
}
db.rawQuery(
"SELECT body_weight_kg,waist_cm FROM body_observations " +
"WHERE id=12 AND observation_id='bo_zone_preserved';",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(81.5, cursor.getDouble(0), 0.0)
assertEquals(91.0, cursor.getDouble(1), 0.0)
}
db.rawQuery(
"SELECT label_name,display_name,equipment_type,load_semantics FROM equipment " +
"WHERE id=999 AND equipment_id='eq_44444444-4444-4444-8444-444444444444';",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("Machine custom", cursor.getString(0))
assertEquals("Machine custom", cursor.getString(1))
assertEquals("strength_machine", cursor.getString(2))
assertEquals("external", cursor.getString(3))
}
db.rawQuery("PRAGMA integrity_check", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals("ok", cursor.getString(0))
}
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
}

View file

@ -0,0 +1,122 @@
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-data migration harness.
*
* CONTRACT: the source path comes only from TRAINLOG_ANDROID_V9_FIXTURE and is
* never opened by the production helper. Two test-owned copies are made first;
* the fixture and the user's installed database cannot be mutated or deleted.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class RealAndroidV9BodyZonesMigrationTest {
private lateinit var context: Context
private val databaseNames = mutableListOf<String>()
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@After
fun tearDown() {
databaseNames.forEach(context::deleteDatabase)
}
@Test
fun realVersionNineCopyMigratesWithoutChangingExistingTables() {
val source = File(System.getenv("TRAINLOG_ANDROID_V9_FIXTURE") ?: "")
assumeTrue("TRAINLOG_ANDROID_V9_FIXTURE is not available", source.isFile)
val beforeName = "body-zones-real-v9-before.db"
val migratedName = "body-zones-real-v9-migrated.db"
databaseNames += listOf(beforeName, migratedName)
val beforePath = context.getDatabasePath(beforeName)
val migratedPath = context.getDatabasePath(migratedName)
beforePath.parentFile?.mkdirs()
Files.copy(source.toPath(), beforePath.toPath(), StandardCopyOption.REPLACE_EXISTING)
Files.copy(source.toPath(), migratedPath.toPath(), StandardCopyOption.REPLACE_EXISTING)
SQLiteDatabase.openDatabase(
beforePath.path, null, SQLiteDatabase.OPEN_READONLY,
).use { before ->
assertEquals(9, scalarInt(before, "PRAGMA user_version;"))
assertEquals("ok", scalarString(before, "PRAGMA integrity_check;"))
before.rawQuery("PRAGMA foreign_key_check;", null).use {
assertFalse(it.moveToFirst())
}
}
val repository = TrainlogRepository(context, migratedName)
assertEquals(23, repository.listExercises().size)
repository.close()
SQLiteDatabase.openDatabase(
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
).use { migrated ->
assertEquals(10, scalarInt(migrated, "PRAGMA user_version;"))
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
assertFalse(it.moveToFirst())
}
migrated.execSQL("ATTACH DATABASE ? AS before_v9;", arrayOf(beforePath.path))
val historicalTables = listOf(
"active_session_draft", "body_observations",
"catalog_exercise_equipment", "continuous_activity",
"draft_continuous_activity", "draft_max_results",
"draft_performed_sets", "draft_session_exercises", "equipment",
"equipment_aliases", "exercise_equipment", "exercises",
"max_results", "performed_sets", "session_exercises", "sessions",
)
historicalTables.forEach { table ->
/* Table names are a closed test constant; values remain bound
* and the EXCEPT comparison covers every column and row. */
assertEquals(0, scalarInt(migrated,
"SELECT COUNT(*) FROM (SELECT * FROM main.$table " +
"EXCEPT SELECT * FROM before_v9.$table);"))
assertEquals(0, scalarInt(migrated,
"SELECT COUNT(*) FROM (SELECT * FROM before_v9.$table " +
"EXCEPT SELECT * FROM main.$table);"))
}
assertEquals(32, scalarInt(migrated,
"SELECT COUNT(*) FROM exercise_body_zones;"))
assertEquals(20, scalarInt(migrated,
"SELECT COUNT(DISTINCT exercise_row_id) FROM exercise_body_zones;"))
assertEquals(23, scalarInt(migrated,
"SELECT COUNT(*) FROM exercise_body_zone_sync;"))
assertEquals(3, scalarInt(migrated,
"SELECT COUNT(*) FROM exercises e WHERE NOT EXISTS(" +
"SELECT 1 FROM exercise_body_zones z WHERE z.exercise_row_id=e.id);"))
migrated.execSQL("DETACH DATABASE before_v9;")
}
}
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)
}
}

View file

@ -1502,7 +1502,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(9, cursor.getInt(0)) assertEquals(10, 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 " +
@ -1629,7 +1629,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(9, cursor.getInt(0)) assertEquals(10, 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())

View file

@ -0,0 +1,39 @@
{
"format": "trainlog-body-zone-catalog",
"version": 1,
"zones": [
{"zone_id":"full_body","display_name":"Corps entier","parent_zone_id":null,"sort_order":0,"kind":"standalone"},
{"zone_id":"upper_body","display_name":"Membres supérieurs","parent_zone_id":null,"sort_order":10,"kind":"group"},
{"zone_id":"chest","display_name":"Pectoraux","parent_zone_id":"upper_body","sort_order":11,"kind":"leaf"},
{"zone_id":"back","display_name":"Dos","parent_zone_id":"upper_body","sort_order":12,"kind":"leaf"},
{"zone_id":"shoulders","display_name":"Épaules","parent_zone_id":"upper_body","sort_order":13,"kind":"leaf"},
{"zone_id":"arms","display_name":"Bras","parent_zone_id":"upper_body","sort_order":14,"kind":"leaf"},
{"zone_id":"core","display_name":"Abdominaux / tronc","parent_zone_id":null,"sort_order":20,"kind":"standalone"},
{"zone_id":"lower_body","display_name":"Membres inférieurs","parent_zone_id":null,"sort_order":30,"kind":"group"},
{"zone_id":"glutes","display_name":"Fessiers","parent_zone_id":"lower_body","sort_order":31,"kind":"leaf"},
{"zone_id":"thighs","display_name":"Cuisses","parent_zone_id":"lower_body","sort_order":32,"kind":"leaf"},
{"zone_id":"calves","display_name":"Mollets","parent_zone_id":"lower_body","sort_order":33,"kind":"leaf"}
],
"exercise_mappings": [
{"exercise_id":"ex_4bd03d55-e644-436e-876e-07837824bde5","exercise_name":"Abdominal","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable et équipement custom durable eq_3f987a36-bbb4-4e29-9c9f-1f201f1596e0 nommé Abdominal."},
{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","exercise_name":"Abdominal crunch","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique abdominal (crunch machine)."},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","exercise_name":"Arm curl","primary_zone_id":"arms","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique arm_curl (curl biceps)."},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","exercise_name":"Back extension","primary_zone_id":"back","secondary_zone_ids":["core"],"decision_source":"Identité stable et occurrence liée à léquipement canonique back_extension (extension lombaire et stabilisation du tronc)."},
{"exercise_id":"ex_8552dd77-fcd7-4f06-a1cc-d956eb1009af","exercise_name":"Chest press","primary_zone_id":"chest","secondary_zone_ids":["shoulders","arms"],"decision_source":"Identité stable et équipement custom durable eq_0a462c1e-9fb6-4fd7-b0a2-53a0e86f33c6 nommé Chest press."},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","exercise_name":"Converging Shoulder Press","primary_zone_id":"shoulders","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à léquipement canonique converging_shoulder_press."},
{"exercise_id":"ex_34a5c9c3-c032-4dfb-be32-8bf09832f72b","exercise_name":"Converting chest press","primary_zone_id":"chest","secondary_zone_ids":["shoulders","arms"],"decision_source":"Identité stable et équipement custom durable eq_c660cd61-5b17-498e-8d51-9eafcf7e2731 nommé Converting chest press."},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","exercise_name":"Diverged seated row","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à léquipement canonique diverging_seated_row."},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","exercise_name":"Diverging lat pulldown","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à léquipement canonique diverging_lat_pulldown."},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","exercise_name":"Hip abduction","primary_zone_id":"glutes","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique hip_abduction (abducteurs/fessiers)."},
{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","exercise_name":"Hip adduction","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique hip_adduction (adducteurs de la cuisse)."},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","exercise_name":"Lat pull","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à léquipement canonique lat_pull."},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","exercise_name":"Leg extension","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique leg_extension (quadriceps)."},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","exercise_name":"Leg press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"decision_source":"Identité stable réconciliée et occurrences liées aux équipements canoniques leg_press/plate_loaded_leg_press."},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","exercise_name":"Prone leg curl","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique prone_leg_curl (ischio-jambiers)."},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","exercise_name":"Rear Delt","primary_zone_id":"shoulders","secondary_zone_ids":["back"],"decision_source":"Identité stable et occurrence liée à léquipement canonique rear_delt_pec_fly, utilisée comme Rear Delt."},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","exercise_name":"Rotary torso","primary_zone_id":"core","secondary_zone_ids":[],"decision_source":"Identité stable correspondant à la rotation du buste; équipement canonique rotary_torso présent dans le manifeste partagé."},
{"exercise_id":"ex_617007f9-7420-4408-91b9-8ffb77900f13","exercise_name":"Seated Leg","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique seated_leg_curl (ischio-jambiers)."},
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","exercise_name":"Seated leg curl","primary_zone_id":"thighs","secondary_zone_ids":[],"decision_source":"Identité stable et occurrence liée à léquipement canonique seated_leg_curl (ischio-jambiers)."},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","exercise_name":"Seated row","primary_zone_id":"back","secondary_zone_ids":["arms"],"decision_source":"Identité stable et occurrence liée à léquipement canonique seated_row."}
]
}

View file

@ -33,7 +33,7 @@ Accueil
Android local database version: Android local database version:
```text ```text
8 10
``` ```
Domain tables cover: Domain tables cover:
@ -45,16 +45,21 @@ session_exercises
performed_sets performed_sets
continuous_activity continuous_activity
body_observations body_observations
exercise_body_zones
exercise_body_zone_sync
``` ```
This database is Android-local. It is not copied to the PC. This database is Android-local. It is not copied to the PC.
Schema v4 introduced `active_session_draft`, `draft_session_exercises`, Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
`draft_performed_sets` and `draft_continuous_activity`. The implemented `draft_performed_sets` and `draft_continuous_activity`. The implemented
additive v4 -> v8 chain preserves catalog, completed sessions/actuals, body additive v4 -> v10 chain preserves catalog, completed sessions/actuals, body
observations and the draft while adding the shared equipment catalogue, observations and the draft while adding the shared equipment catalogue,
occurrence-level equipment links and stable completed/draft `entry_id` values. occurrence-level equipment links and stable completed/draft `entry_id` values.
Exactly one active draft is supported; it is separate from completed history. Exactly one active draft is supported; it is separate from completed history.
Schema v9 adds explicit completed/draft MAX results. Schema v10 additively
stores direct primary/secondary body-zone relations and their private sync
baseline; the taxonomy itself remains the shared manifest asset.
## 4. Exercise catalog ## 4. Exercise catalog
@ -65,6 +70,8 @@ name
recording_mode recording_mode
tracking_mode tracking_mode
data_fields data_fields
primary_zone_id nullable only for explicit unclassified/history cases
secondary_zone_ids zero or more distinct canonical IDs
``` ```
Stable identity: Stable identity:
@ -78,6 +85,19 @@ collisions.
An exercise may be created standalone or inline while building a session. An exercise may be created standalone or inline while building a session.
The form groups translated values from `catalog/body-zones-v1.json` under
**Membres supérieurs** and **Membres inférieurs**, with autonomous **Corps
entier** and **Abdominaux / tronc** entries. Group nodes organize and filter;
they cannot be assigned directly. A new set-based exercise requires one
primary zone. Selecting it removes/disables that same ID among secondaries.
Historical unclassified exercises remain visible as **Zone : Non renseignée**
and may be classified later.
The existing exercise list combines SQL-backed normalized prefix search with a
zone filter. Parent filters include descendants, **Non renseignés** selects
only exercises with no relations, and **Toutes les zones** never hides those
rows. Each row displays primary, secondaries and the primary's derived group.
### Editing an exercise ### Editing an exercise
Every existing catalog item exposes **Modifier**. Editing a name trims its Every existing catalog item exposes **Modifier**. Editing a name trims its
@ -197,6 +217,9 @@ profile. Completed and draft occurrence row IDs, `entry_id`, positions, values,
equipment references and the active selection are moved transactionally. Any equipment references and the active selection are moved transactionally. Any
incompatible condition rejects the catalog transaction; name equality alone incompatible condition rejects the catalog transaction; name equality alone
never authorizes a merge. never authorizes a merge.
Zone relations follow the same row-identity move only when one side is empty or
both states are equal. Two different non-empty states are an explicit conflict;
the repository never invents an automatic union of secondary zones.
## 7. Session history ## 7. Session history
@ -261,6 +284,14 @@ definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict
are idempotent and divergent same-ID definitions conflict. Supplied-manifest are idempotent and divergent same-ID definitions conflict. Supplied-manifest
IDs are reserved. IDs are reserved.
Android also writes `trainlog-exercise-body-zones-v1.json`, the sole zone
companion used in both directions. It contains stable exercise/zone IDs only.
After the file write succeeds, an identical local replay establishes/refreshes
the publisher's shared baseline. A one-sided change is applied transactionally,
and simultaneous divergence returns an explicit conflict without changing
local relations. The unclassified state contains neither a primary nor orphan
secondaries.
The user does not need a separate manual export step before synchronization. The user does not need a separate manual export step before synchronization.
An active draft is never included in completed history, session detail or this An active draft is never included in completed history, session detail or this
@ -296,6 +327,11 @@ Android writes:
trainlog-sync-request-v1.json trainlog-sync-request-v1.json
``` ```
If MediaStore cannot reopen an older MTP-created canonical object, it may
publish the exact collision sibling `trainlog-sync-request-v1 (N).json`. The
desktop engine selects the newest canonical-or-suffixed request
deterministically, while the stable `request_id` remains the replay boundary.
and waits for a matching: and waits for a matching:
```text ```text
@ -309,8 +345,10 @@ 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 v9 provides before a received V2 association references them. Android schema v10 provides
the non-destructive v7 -> v8 migration required for `load_semantics = none`. the non-destructive v7 -> v8 migration required for `load_semantics = none`.
After catalog/session/equipment reconciliation, Android applies the same
body-zone companion so custom exercises receive their classifications.
A receipt belonging to another request is ignored as pending rather than A receipt belonging to another request is ignored as pending rather than
misreported as the current result. misreported as the current result.
@ -338,7 +376,7 @@ cd android
printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties printf 'sdk.dir=%s\n' "$HOME/Android/Sdk" > local.properties
JAVA_HOME=/usr/lib/jvm/java-17-openjdk \ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
./gradlew assembleDebug ./gradlew testDebugUnitTest assembleDebug
``` ```
Install to a connected test device: Install to a connected test device:
@ -349,7 +387,8 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
`local.properties` is local machine configuration and must not be committed. `local.properties` is local machine configuration and must not be committed.
The prior host regression suite had 8 tests and the prior device The current JVM host regression suite has 44 passing tests when the retained
real v9 fixture is enabled. The prior device
instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests
using an isolated database and no shared export). That device matrix exercised using an isolated database and no shared export). That device matrix exercised
production `MainActivity`, production `MainActivity`,
@ -438,12 +477,18 @@ left intact as the crash-safe baseline until finalization.
## 17. Audited limitations ## 17. Audited limitations
Body Zones V1 defines no custom zone creation and no session generator. The
repository already exposes manifest lookup/hierarchy, descendant and
primary-only exercise filtering, direct exercise relations and the existing
latest-MAX/history reads needed for future composition; it calculates no
suggested load.
Android's stored `normalized_name` currently removes diacritics, while the Android's stored `normalized_name` currently removes diacritics, while the
frozen desktop/Python normalization contract uses NFC, Unicode whitespace 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 v9. collisions. It is not silently changed inside schema v10.
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
@ -459,7 +504,7 @@ occurrence references.
The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field The PC-catalog V1 inbox validates required IDs, modes, names, and bounded field
masks, but unlike the newer mobile V2 and equipment-companion parsers it does masks, but unlike the newer mobile V2 and equipment-companion parsers it does
not reject every unknown root or item key. Tightening this published V1 reader not reject every unknown root or item key. Tightening this published V1 reader
requires a compatibility decision rather than an incidental schema-v9 change. requires a compatibility decision rather than an incidental schema-v10 change.
Android requires `data_fields = 0` for `SETS`, while the desktop model/API Android requires `data_fields = 0` for `SETS`, while the desktop model/API
currently accepts known supplemental bits on either recording mode. Supplied currently accepts known supplemental bits on either recording mode. Supplied

View file

@ -38,6 +38,7 @@ Responsibilities:
- exercise catalog entry; - exercise catalog entry;
- stable-ID exercise rename/editing; - stable-ID exercise rename/editing;
- canonical body-zone selection, display and descendant filtering;
- workout-session recording; - workout-session recording;
- performed set entry; - performed set entry;
- continuous-activity entry; - continuous-activity entry;
@ -56,6 +57,7 @@ The C17 core owns:
- desktop SQLite persistence; - desktop SQLite persistence;
- exercise/catalog rules; - exercise/catalog rules;
- generated body-zone taxonomy and relation APIs;
- profile-aware session data; - profile-aware session data;
- body data; - body data;
- ID and time helpers; - ID and time helpers;
@ -74,6 +76,7 @@ It consumes core services for:
- session entry/editing; - session entry/editing;
- history; - history;
- exercise performance; - exercise performance;
- exercise body-zone creation/edit/detail and filters;
- body tracking; - body tracking;
- graphs; - graphs;
- manual synchronization; - manual synchronization;
@ -116,17 +119,39 @@ assistance
Continuous work is persisted separately from performed sets. Continuous work is persisted separately from performed sets.
Body-zone semantics are a second independent metadata axis:
```text
exercise -> exercise_body_zones -> canonical body-zone manifest
role = primary | secondary
```
The repository-level `catalog/body-zones-v1.json` is the sole taxonomy source.
Android reads it as an asset and the desktop generates a bounded C
representation at build time. Stable `zone_id` values cross persistence and
synchronization boundaries; translated display names do not. `upper_body` and
`lower_body` are hierarchy groups whose descendant membership is derived at
query time, never persisted redundantly. `full_body` and `core` are autonomous.
An unclassified exercise has no relation at all: a secondary-only state is
invalid at repository, database and exchange boundaries rather than being
silently rendered as unclassified.
## 4. Persistence ownership ## 4. Persistence ownership
### Desktop ### Desktop
Desktop SQLite schema v10 is canonical long-term history. Its v9 -> v10 Desktop SQLite schema v11 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`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion. occur more than once in one session without identity fusion.
The additive v10 -> v11 migration creates direct exercise/body-zone relations
and a private synchronization baseline, then seeds only stable-ID mappings
whose decision evidence is recorded in the manifest. It never rewrites an
exercise, occurrence or history row.
Main tables: Main tables:
```text ```text
@ -138,15 +163,21 @@ continuous_activity
max_results max_results
body_observations body_observations
custom_equipment custom_equipment
exercise_body_zones
exercise_body_zone_sync
``` ```
### Android ### Android
Android has an independent local SQLite schema, currently v9. Completed and Android has an independent local SQLite schema, currently v10. 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.
Its v9 -> v10 migration adds equivalent exercise/body-zone relations and seeds
the same manifest mappings. User creation and editing replace name/profile and
zone relations in one repository transaction.
It mirrors domain concepts needed for capture, but its schema version is not It mirrors domain concepts needed for capture, but its schema version is not
coupled to the desktop schema. coupled to the desktop schema.
@ -196,6 +227,7 @@ trainlog-mobile-export v2
trainlog-pc-catalog v1 trainlog-pc-catalog v1
trainlog-equipment-associations v2 trainlog-equipment-associations v2
trainlog-equipment-definitions v1 trainlog-equipment-definitions v1
trainlog-exercise-body-zones v1
trainlog-sync-request v1 trainlog-sync-request v1
trainlog-sync-receipt v1 trainlog-sync-receipt v1
``` ```
@ -212,6 +244,11 @@ to Android. The filename identifies direction; the JSON format and version do
not change. V1 historical artifacts remain readable under their frozen not change. V1 historical artifacts remain readable under their frozen
contracts. contracts.
`trainlog-exercise-body-zones-v1.json` is the one direction-neutral zone
companion in both directions. It carries only `exercise_id`, a nullable
`primary_zone_id` and `secondary_zone_ids`; taxonomy definitions stay in the
canonical manifest.
## 6. Direct MTP transport ## 6. Direct MTP transport
Linux transport: Linux transport:
@ -274,6 +311,8 @@ artifact, mobile-export v2, and equipment-associations v2. The PC-to-Android
direction publishes PC equipment-definitions v1 before dependent artifacts, direction publishes PC equipment-definitions v1 before dependent artifacts,
then publishes the PC catalog v1, PC mobile-export v2 (including completed then publishes the PC catalog v1, PC mobile-export v2 (including completed
sessions and body observations), and equipment-associations v2. sessions and body observations), and equipment-associations v2.
Both directions also transfer the same body-zone companion after exercise
definitions are established and before completion of the direction.
The engine performs the applicable direction steps: The engine performs the applicable direction steps:
@ -281,8 +320,8 @@ The engine performs the applicable direction steps:
1. direct-MTP device/storage discovery 1. direct-MTP device/storage discovery
2. exchange-folder resolution 2. exchange-folder resolution
3. definition artifact transfer and reconciliation before dependent V2 data 3. definition artifact transfer and reconciliation before dependent V2 data
4. strict transactional Android -> PC import when selected 4. strict transactional Android -> PC import and body-zone reconciliation when selected
5. PC catalog, mobile/body, and association export when selected 5. PC catalog, body-zone, mobile/body, and association export when selected
6. direct-MTP publication of the selected PC -> Android artifacts 6. direct-MTP publication of the selected PC -> Android artifacts
7. optional request receipt publication 7. optional request receipt publication
8. structured run-history recording 8. structured run-history recording
@ -294,7 +333,7 @@ same-ID definitions, unknown references, and association conflicts are reported
explicitly. The affected persisted content is preserved on conflict; the engine explicitly. The affected persisted content is preserved on conflict; the engine
does not silently overwrite it. does not silently overwrite it.
There is no exercise, session, body-observation, or equipment-definition There is no exercise, session, body-observation, body-zone-relation, or equipment-definition
tombstone in the current formats. Omitting one of those objects from a later tombstone in the current formats. Omitting one of those objects from a later
snapshot is not a deletion request. The only explicit removal signal is snapshot is not a deletion request. The only explicit removal signal is
equipment-association V2 `state: cleared`, scoped to one equipment-association V2 `state: cleared`, scoped to one
@ -400,13 +439,23 @@ schema change.
Each mutating importer has strict validation and a SQLite transaction. The V2 Each mutating importer has strict validation and a SQLite transaction. The V2
association companion is a strict corroboration of the equipment already association companion is a strict corroboration of the equipment already
carried by the mobile snapshot and does not rewrite divergent state. However, carried by the mobile snapshot and does not rewrite divergent state. However,
one definitions/mobile/associations publication has no common generation one definitions/mobile/body-zones/associations publication has no common generation
manifest and is not one cross-artifact database transaction. A late association manifest and is not one cross-artifact database transaction. A late association
conflict can therefore follow a successfully committed definitions or mobile conflict can therefore follow a successfully committed definitions or mobile
import; replay remains idempotent and existing conflicting content is not import; replay remains idempotent and existing conflicting content is not
overwritten. A future batch protocol must be separately versioned rather than overwritten. A future batch protocol must be separately versioned rather than
retrofitted into frozen formats. retrofitted into frozen formats.
Body-zone concurrency uses an internal canonical-state baseline. Equal states
are idempotent; the sole changed side wins; if both local and incoming states
diverge from the baseline, the companion reports a conflict and rolls back.
Secondary lists are never merged by union. A safe exercise-identity merge may
carry the only non-empty zone state, but rejects two different non-empty states
and clears the baseline because identity reconciliation is not acknowledgement.
After a companion has actually been published, the publisher records that
exact snapshot as its own baseline too; a failed file/MTP publication never
acknowledges data that the peer could not have observed.
Android and desktop also have different stored name-normalization behavior: Android and desktop also have different stored name-normalization behavior:
Android removes diacritics, while the frozen desktop/Python rule preserves them Android removes diacritics, while the frozen desktop/Python rule preserves them
through NFC plus Unicode case folding. Correcting existing Android keys requires through NFC plus Unicode case folding. Correcting existing Android keys requires

View file

@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V10=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V10=PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_DURABLE=PASS
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
@ -56,10 +56,15 @@ EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS MAX_TEST_RESUME_STABLE_ID=PASS
BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_TUI_REAL_VALIDATION=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
``` ```
## Desktop ## Desktop
@ -67,11 +72,13 @@ HARDWARE_SYNC_VALIDATION=PASS
Implemented: Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); - C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
- SQLite schema v10, with stable ordered `session_exercises.entry_id`, - SQLite schema v11, 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
preserving historic NULL and positive rows; preserving historic NULL and positive rows;
- canonical generated body-zone taxonomy, direct primary/secondary exercise
relations, stable-ID-only initial migration and descendant-aware filtering;
- direct session entry; - direct session entry;
- normal desktop SETS planning followed by the table-only explicit actual-row - normal desktop SETS planning followed by the table-only explicit actual-row
editor; zero-row completion is rejected while MAX and continuous entries keep editor; zero-row completion is rejected while MAX and continuous entries keep
@ -109,11 +116,13 @@ Primary navigation:
Implemented: Implemented:
- native Kotlin/Compose application; - native Kotlin/Compose application;
- local SQLite database v9, with non-destructive v3 -> v9 migration; - local SQLite database v10, with non-destructive v3 -> v10 migration;
- one durable active-session draft, Home resume and raw-form restoration; - one durable active-session draft, Home resume and raw-form restoration;
- explicit confirmed discard and atomic completed-save/draft-clear; - explicit confirmed discard and atomic completed-save/draft-clear;
- exercise creation; - exercise creation;
- stable-ID exercise rename/editing with referenced-profile protection; - stable-ID exercise rename/editing with referenced-profile protection;
- primary/secondary body-zone selection, display, hierarchy filtering and
prefix-search composition, including visible unclassified exercises;
- inline exercise creation during session entry; - inline exercise creation during session entry;
- profile-aware session recording; - profile-aware session recording;
- heterogeneous repetition-set entry; - heterogeneous repetition-set entry;
@ -158,12 +167,14 @@ Android -> PC
trainlog-mobile-equipment-definitions-v1.json trainlog-mobile-equipment-definitions-v1.json
trainlog-mobile-export-v2.json trainlog-mobile-export-v2.json
trainlog-equipment-associations-v2.json trainlog-equipment-associations-v2.json
trainlog-exercise-body-zones-v1.json
PC -> Android PC -> Android
trainlog-pc-equipment-definitions-v1.json trainlog-pc-equipment-definitions-v1.json
trainlog-pc-catalog-v1.json trainlog-pc-catalog-v1.json
trainlog-pc-mobile-export-v2.json trainlog-pc-mobile-export-v2.json
trainlog-equipment-associations-v2.json trainlog-equipment-associations-v2.json
trainlog-exercise-body-zones-v1.json
Android -> PC agent Android -> PC agent
trainlog-sync-request-v1.json trainlog-sync-request-v1.json
@ -192,6 +203,15 @@ V2 resolves equipment by `(session_id, entry_id)`, never by display name or
catalogue identity alone. V1 files remain legacy-compatible and do not gain catalogue identity alone. V1 files remain legacy-compatible and do not gain
multi-occurrence semantics retroactively. multi-occurrence semantics retroactively.
Body-zone relations have one direction-neutral companion and are keyed only by
canonical zone IDs and `exercise_id`. Identical state is an idempotent skip; a
successful publication records that exact state as the publisher baseline, so
a later peer-only edit of a new custom exercise is accepted. Simultaneous
divergence is an explicit conflict and secondary lists are never unioned. The
unclassified state has no relations; orphan secondary rows are invalid. Parent
groups are derived from the manifest and never serialized as exercise
relations.
Supplied equipment remains generated from `catalog/equipment-v1.json`; its IDs Supplied equipment remains generated from `catalog/equipment-v1.json`; its IDs
are reserved. User-created definitions synchronize additively through the are reserved. User-created definitions synchronize additively through the
directional definitions V1 artifacts. Equal same-ID definitions are idempotent; directional definitions V1 artifacts. Equal same-ID definitions are idempotent;
@ -226,43 +246,85 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
36/36 Meson tests PASS for the current desktop schema v10 baseline 39/39 Meson tests PASS for the current desktop schema v11 baseline
JSON valid/invalid checks PASS JSON valid/invalid checks PASS
import-contract validator 6/6 PASS import-contract validator 6/6 PASS
ASan/UBSan 14/14 Meson tests PASS postrepair ASan/UBSan 39/39 Meson tests PASS with leak detection
standalone public-header C17 syntax PASS
real Notcurses binary zone workflows PASS in Kitty
git diff --check PASS git diff --check PASS
``` ```
Android: Android:
```text ```text
37 Android unit tests PASS Android unit tests 44/44 PASS with a real v9 copy enabled, including body-zone
v9 -> v10 migration and sync
assembleDebug PASS assembleDebug PASS
APK Signature Scheme v2 verification PASS
APK certificate SHA-256 matches the local debug keystore
APK SHA-256 5355f54e83f00ef31dc0e2f1db866c43ea3e9d880875f67878b1ffe169b5dc50
``` ```
Current real-data/environment boundary: Current real-data/environment boundary:
```text ```text
BODY_ZONES_DESKTOP_BACKUP=PASS
BODY_ZONES_DESKTOP_REAL_V10_TO_V11=PASS
BODY_ZONES_DESKTOP_HISTORY_ROW_EQUALITY=PASS
BODY_ZONES_DESKTOP_RELATIONS=32/20_EXERCISES
BODY_ZONES_DESKTOP_UNCLASSIFIED=3
BODY_ZONES_ANDROID_INSTALL=PASS
BODY_ZONES_ANDROID_REAL_V9_COPY_TO_V10=PASS
BODY_ZONES_ANDROID_INSTALLED_DB_V10=PASS
BODY_ZONES_LIVE_MTP_ROUNDTRIP=PASS
BODY_ZONES_LIVE_MTP_SECOND_PASS_IDEMPOTENT=PASS
REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS REAL_ANDROID_ARTIFACT_COPY_ROUNDTRIP=PASS
PC_EXPORT_ANDROID_IMPORT_THREE_PASS_COPY=PASS PC_EXPORT_ANDROID_IMPORT_THREE_PASS_COPY=PASS
ANDROID_INSTALL_R_DATABASE_HASH_PRESERVED=PASS ANDROID_INSTALL_R_DATABASE_HASH_PRESERVED=PASS
CURRENT_SANDBOX_LIBMTP_OPEN=BLOCKED
REAL_PC_TO_ANDROID_TWO_RUN=BLOCKED_BY_SANDBOX_LIBMTP_OPEN
``` ```
The current sandbox discovers the connected Samsung MTP interface but The real desktop v10 database was backed up with SQLite to
`libusb_open()` cannot acquire it. It also exposes the canonical desktop DB as `backups/body-zones-v1-20260909T101310Z/trainlog-v10-before.db` under the Trainlog
read-only. The failed first write left that DB byte-for-byte logically user-data directory (SHA-256
unchanged and integrity-clean; Android remained force-stopped and was not `9d5dcacd8be941be17bd3b98bbbc1815944fec9dc1b18133111ca481f50684e9`).
modified through ADB. Therefore this checkpoint makes no new direct-device MTP The production binary migrated it to v11; integrity/FK checks pass and every
claim and does not claim that the real stores have consumed the reconciled row of all eight historical tables compares equal in both directions with the
artifacts. backup. The migration added 32 direct relations for 20 of 23 exercises and
left Gym échauffement, Gym/Échauffement and Marche unclassified.
The Samsung SM-G990B application data was freshly backed up before installation
under `backups/body-zones-v1-android-20260909TVFprJ8/`. The coherent v9 SQLite
copy has SHA-256
`c402b69cdbfa4912eec1553a84754af1e892fa8da0cae6c3cbd14c4f10198401`;
its integrity check passed and its foreign-key check was empty. The installed,
built and established-keystore certificate fingerprints all matched
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`
before `adb install -r`. The normal installed migration reached v10 with every
row of the 16 existing application tables plus `android_metadata` equal in both
directions to the v9 backup. Integrity/FK checks pass, 32 direct relations cover
20 of 23 exercises, and Gym échauffement, Gym/Échauffement and Marche remain
unclassified.
The real Android UI displayed primary, secondary and derived-group metadata;
the chest, upper-body descendant, lower-body descendant and unclassified
filters; and the `Dos` + `lat` prefix intersection. Editing `Lat pull`
preloaded `Dos` and secondary `Bras`; cancellation left every application table
unchanged. Live sync runs `sy_0b00dc46-d899-4865-8718-c95085a380b2` and
`sy_fb0630a8-1938-4d62-9836-b0281955f625` both completed successfully. The
second run reported zero additions/reconciliations, both stores retained the
same 32-relation stable-ID hash, and Android application tables plus companion
exercise states compared equal to the first pass. The engine accepts the exact
MediaStore collision family `trainlog-sync-request-v1 (N).json`; request-ID
replay protection remains authoritative.
## Current implementation cursor ## Current implementation cursor
Exercise reconciliation, definition-first V2 synchronization and the current Body Zones V1, exercise reconciliation, definition-first V2 synchronization
real-data importer/exporter copy validation are complete. No product-roadmap and the current real-data importer/exporter copy validation are complete. The
ordering change was made by this corrective tranche. body-zone taxonomy now supplies the read boundary required by a future
zone-driven planner; no session generator or proposed-load calculation is part
of this tranche.
```text ```text
MEASURED_MAX_V1=PASS MEASURED_MAX_V1=PASS
@ -287,7 +349,7 @@ ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
EXPLICIT_MAX_RESULTS_V1=PASS EXPLICIT_MAX_RESULTS_V1=PASS
MAX_TEST_RESUME_STABLE_ID=PASS MAX_TEST_RESUME_STABLE_ID=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
``` ```
A measured maximum belongs to an exercise occurrence in an explicit `max_test` A measured maximum belongs to an exercise occurrence in an explicit `max_test`
@ -317,7 +379,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
BODY_PROPORTION_RATIOS=PASS BODY_PROPORTION_RATIOS=PASS
BODY_SYMMETRY_ANALYTICS=PASS BODY_SYMMETRY_ANALYTICS=PASS
NO_ESTIMATE_PERSISTENCE=PASS NO_ESTIMATE_PERSISTENCE=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
``` ```
Android remains capture-only for this feature. Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=10 TRAINLOG_DATABASE_SCHEMA_VERSION=11
DATABASE_SCHEMA_V10=PASS DATABASE_SCHEMA_V11=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -23,7 +23,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
10 11
``` ```
The independent actual-set loads documented in the current desktop, Android The independent actual-set loads documented in the current desktop, Android
@ -61,6 +61,13 @@ checks lossless migration, rollback after an injected rebuild-name collision,
`PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key `PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key
enforcement, and rejection of negative loads or invalid metric shapes. enforcement, and rejection of negative loads or invalid metric shapes.
Version 11 is additive. It creates `exercise_body_zones` and the private
`exercise_body_zone_sync` comparison baseline, then inserts only the exact
stable-ID mappings declared with evidence in `catalog/body-zones-v1.json`.
There is no exercise-ID, occurrence-ID, session, performed-set, MAX, equipment
or body-observation rewrite. The migration is one transaction and uncertain
historical exercises remain valid with no relation.
A schema fixture must represent the real historical structure. Rewriting only A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test. `user_version` is not an acceptable migration test.
@ -96,6 +103,31 @@ Rules include:
- unknown supplemental field bits are rejected; - unknown supplemental field bits are rejected;
- normalized names remain unique. - normalized names remain unique.
### `exercise_body_zones`
Direct exercise-to-zone relations:
```text
exercise_row_id foreign key -> exercises(id), cascade delete
zone_id stable ID from body-zones-v1.json
role primary | secondary
PRIMARY KEY exercise_row_id, zone_id
partial UNIQUE one role=primary row per exercise
```
The composite key prevents one zone from being both primary and secondary.
Public writers validate that every ID exists and is not a group. An exercise
with no rows is explicitly unclassified; a secondary-only state is rejected as
corruption. Parent relations are derived from the manifest during filtering
and are not stored here.
`exercise_body_zone_sync(exercise_row_id, synced_state)` is internal sync
metadata, not domain data. Its deterministic state records a nullable primary
and byte-sorted secondary IDs so one-sided edits can be distinguished from a
simultaneous conflict. It is never exposed as a translated value or used to
merge secondary sets by union. The publishing peer records the exact snapshot
as its baseline only after the companion has been published successfully.
### `sessions` ### `sessions`
```text ```text
@ -292,6 +324,10 @@ A failed replacement rolls back to the previously persisted session.
Body-observation editing preserves its stable identity, timestamp, and optional Body-observation editing preserves its stable identity, timestamp, and optional
session link. session link.
Exercise creation/editing and replacement of all direct body-zone relations is
one transaction. Invalid or duplicate IDs leave both exercise metadata and the
prior relation set unchanged.
## 7. Mobile import semantics ## 7. Mobile import semantics
`tools/import_mobile_export.py` validates the complete mobile snapshot before `tools/import_mobile_export.py` validates the complete mobile snapshot before
@ -300,7 +336,7 @@ committing database changes.
Properties: Properties:
```text ```text
schema-v5-through-v8 aware schema-v5-through-v11 aware
transactional transactional
idempotent by stable IDs idempotent by stable IDs
profile-aware catalog reconciliation profile-aware catalog reconciliation
@ -317,6 +353,8 @@ desktop identity is deterministic canonical ownership. The mask union retains
the richer capability, while `session_exercises.data_fields` and all child the richer capability, while `session_exercises.data_fields` and all child
values remain unchanged. A missing historic optional value stays `NULL`. values remain unchanged. A missing historic optional value stays `NULL`.
Incomparable masks or modes reject the entire mobile-import transaction. Incomparable masks or modes reject the entire mobile-import transaction.
On v11, a safe duplicate-row merge also preserves the only non-empty body-zone
state. Different non-empty states conflict; no relation list is unioned.
## 8. Units ## 8. Units
@ -334,13 +372,17 @@ distance km
The Android SQLite database is independent. The Android SQLite database is independent.
Current Android-local version: **9**. The explicit migration chain adds the Current Android-local version: **10**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
max and load form text, and the optional stable source session used to resume a max and load form text, and the optional stable source session used to resume a
completed Test max. completed Test max.
Version 10 adds `exercise_body_zones` and `exercise_body_zone_sync`, validates
IDs against the shared manifest asset, and seeds the same stable-ID mappings as
desktop v11 without modifying completed or draft work.
| Table | Ownership | | Table | Ownership |
| --- | --- | | --- | --- |
| `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text including MAX, optional resumed source session, update time | | `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text including MAX, optional resumed source session, update time |
@ -351,6 +393,8 @@ completed Test max.
| `max_results` | Positive explicit max weight, one-to-one with a completed occurrence | | `max_results` | Positive explicit max weight, one-to-one with a completed occurrence |
| `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs | | `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs |
| `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation | | `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation |
| `exercise_body_zones` | Direct primary/secondary stable zone IDs; no derived parent rows |
| `exercise_body_zone_sync` | Private common-state baseline for explicit conflict detection |
Foreign keys remain enabled. Draft deletion cascades only through draft child Foreign keys remain enabled. Draft deletion cascades only through draft child
tables; it cannot delete catalog entries or completed history. The repository tables; it cannot delete catalog entries or completed history. The repository
@ -397,12 +441,15 @@ Migration-specific regression coverage includes:
```text ```text
schema_v5_migration schema_v5_migration
schema_v7_migration schema_v7_migration
body_zones
body_zone_catalog_validation
body_zone_sync
exercise_reconciliation exercise_reconciliation
max_results max_results
max_sync max_sync
``` ```
The current normal desktop suite contains 34 tests. The current normal desktop suite contains 39 tests.
## 11. Explicit and legacy measured maxima ## 11. Explicit and legacy measured maxima
@ -473,10 +520,14 @@ durable-draft occurrences. A resumed max-test draft preserves its source
`session_id`; atomic finalization replaces that session's ordered children `session_id`; atomic finalization replaces that session's ordered children
instead of generating a second session. instead of generating a second session.
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;
neither migration changes the occurrence/equipment/MAX graph described above.
## 13. Body analytics persistence rule ## 13. Body analytics persistence rule
Body analytics still require no schema change beyond schema v8; schema v9 does Body analytics still require no dedicated schema change; schemas v9 through
not alter their storage. v11 do not alter their measurement storage.
Canonical persistence continues to contain only measurements actually entered Canonical persistence continues to contain only measurements actually entered
by the user. by the user.

View file

@ -458,7 +458,8 @@ The following are deliberately not represented by v1:
- measured per-set rest; - measured per-set rest;
- supersets/circuits as first-class objects; - supersets/circuits as first-class objects;
- arbitrary custom set metrics; - arbitrary custom set metrics;
- muscle-group classification; - exercise body-zone relations (carried by the separate versioned Body Zones
V1 companion, never added to this frozen format);
- machine seat/settings metadata; - machine seat/settings metadata;
- photos; - photos;
- cloud synchronization. - cloud synchronization.

View file

@ -9,6 +9,7 @@ PROFILE_AWARE_ANDROID=PASS
CONTINUOUS_ACTIVITY=PASS CONTINUOUS_ACTIVITY=PASS
VARIABLE_REPETITION_SETS=PASS VARIABLE_REPETITION_SETS=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
BODY_ZONES_V1=PASS
``` ```
## 1. Metadata axes ## 1. Metadata axes
@ -70,7 +71,46 @@ Rameur
CONTINUOUS + DURATION + DISTANCE_KM CONTINUOUS + DURATION + DISTANCE_KM
``` ```
## 3. Load semantics ## 3. Body-zone metadata
Exercise behavior and body-zone classification are independent. The sole V1
taxonomy is `catalog/body-zones-v1.json`:
```text
full_body Corps entier
upper_body Membres supérieurs (group)
chest Pectoraux
back Dos
shoulders Épaules
arms Bras
core Abdominaux / tronc
lower_body Membres inférieurs (group)
glutes Fessiers
thighs Cuisses
calves Mollets
```
An exercise stores at most one direct `primary` relation and any number of
distinct `secondary` relations. Secondary relations require that primary; an
unclassified exercise has no relation. The same zone cannot have both roles.
Group nodes are not assignable: a direct `chest` relation is sufficient for an
`upper_body` descendant query. `full_body` is not a synonym for all zones, and
`core` is not implicitly upper or lower body. Cardio is an activity profile,
not a body zone.
New interactive `SETS` creation asks for a primary zone. Historical or
objectively ambiguous exercises may remain without relations and are displayed
and filterable as **Non renseignés**. Initial migration decisions use exact
stable `exercise_id` values and recorded equipment/catalog evidence, never a
general name rule.
The read surface supports zone lookup, children, ancestors, direct relations,
primary/secondary selection and exercises for a zone with optional descendants
or primary-only participation. Those exercise IDs compose with existing
performance/MAX history readers, so a future session generator needs no new
duplicated MAX or history storage. No generator or load proposal exists yet.
## 4. Load semantics
Load mode is session-specific: Load mode is session-specific:
@ -88,7 +128,7 @@ better when comparing otherwise equivalent performance.
Machine-displayed kilograms are stored faithfully without claiming mechanical Machine-displayed kilograms are stored faithfully without claiming mechanical
equivalence across different machines. equivalence across different machines.
## 4. Set-based work ## 5. Set-based work
`SETS + REPS` stores one performed-set row per actual set. `SETS + REPS` stores one performed-set row per actual set.
@ -114,7 +154,7 @@ or progression calculations.
`SETS + DURATION` likewise stores one actual duration per performed set. `SETS + DURATION` likewise stores one actual duration per performed set.
## 5. Planned versus actual ## 6. Planned versus actual
Desktop-created set sessions may carry explicit planned targets. Desktop-created set sessions may carry explicit planned targets.
@ -131,7 +171,7 @@ target_duration_seconds = NULL
Do not derive a fake target from heterogeneous actual sets. Do not derive a fake target from heterogeneous actual sets.
## 6. Continuous work ## 7. Continuous work
Continuous activity does not ask for: Continuous activity does not ask for:
@ -156,7 +196,7 @@ and configured supplemental values.
No fake performed set is created. No fake performed set is created.
## 7. Historical interpretation ## 8. Historical interpretation
Desktop `session_exercises` snapshot: Desktop `session_exercises` snapshot:
@ -177,7 +217,7 @@ historic `data_fields = 1` walk remains a speed-only occurrence after its
catalog definition becomes `data_fields = 3`. Its absent distance stays catalog definition becomes `data_fields = 3`. Its absent distance stays
absent/`NULL`; synchronization does not infer it from duration and speed. absent/`NULL`; synchronization does not infer it from duration and speed.
## 8. Android/TUI parity ## 9. Android/TUI parity
Both interfaces use the same metadata axes. Both interfaces use the same metadata axes.
@ -192,6 +232,8 @@ data_fields
``` ```
Creating an exercise inline on Android or desktop follows the same model rules. Creating an exercise inline on Android or desktop follows the same model rules.
Body zones use their separate direction-neutral V1 companion rather than
changing this catalog artifact or frozen session V1.
### Safe V2 identity reconciliation ### Safe V2 identity reconciliation
@ -216,7 +258,7 @@ never establish identity.
This is a synchronization-V2 policy. It does not relax or redefine the frozen This is a synchronization-V2 policy. It does not relax or redefine the frozen
Trainlog JSON v1 document rules. Trainlog JSON v1 document rules.
## 9. Exchange boundaries ## 10. Exchange boundaries
Frozen Trainlog session JSON v1 remains unchanged. Frozen Trainlog session JSON v1 remains unchanged.
@ -228,7 +270,7 @@ Continuous activity must never be:
- converted into a fake set; - converted into a fake set;
- silently discarded. - silently discarded.
## 10. Measured max semantics ## 11. Measured max semantics
`session_type = max_test` is an explicit semantic boundary. `session_type = max_test` is an explicit semantic boundary.

View file

@ -14,8 +14,8 @@ GATE_1=PASS
GATE_2=PASS GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V10=PASS DESKTOP_SCHEMA_V11=PASS
ANDROID_LOCAL_DATABASE_V9=PASS ANDROID_LOCAL_DATABASE_V10=PASS
DIRECT_MTP_TRANSPORT=PASS DIRECT_MTP_TRANSPORT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
@ -27,8 +27,11 @@ MAX_TEST_RESUME_STABLE_ID=PASS
BODY_ANALYTICS_V1=PASS BODY_ANALYTICS_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=36/36 PASS DESKTOP_TESTS=39/39 PASS
TUI_NOTCURSES_V1=PASS TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=PASS NOTCURSES_TRUECOLOR_THEME=PASS
@ -40,6 +43,7 @@ The current product baseline includes:
- usable Notcurses desktop TUI; - usable Notcurses desktop TUI;
- native Android capture client; - native Android capture client;
- exercise catalog; - exercise catalog;
- canonical hierarchical body zones, primary/secondary relations and filters;
- profile-aware set and continuous activity; - profile-aware set and continuous activity;
- heterogeneous repetition sets; - heterogeneous repetition sets;
- session history and editing; - session history and editing;
@ -53,6 +57,12 @@ The current product baseline includes:
- supplied and custom equipment definitions with occurrence-level links; - supplied and custom equipment definitions with occurrence-level links;
- shared synchronization engine and `trainlog-syncd`. - shared synchronization engine and `trainlog-syncd`.
`BODY_ZONES_V1` is complete infrastructure for later planning: one shared
manifest, desktop v11/Android v10 relations, Android/TUI edit and display,
descendant-aware filters, unclassified history and one explicit-conflict sync
companion. It does not implement a session generator, custom zones or proposed
loads.
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits `EXERCISE_EDIT_V1` is a completed capture correction: Android permits
stable-ID renames, protects referenced profiles, and reconciles same-ID display stable-ID renames, protects referenced profiles, and reconciles same-ID display
metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only
@ -131,8 +141,8 @@ useful duplicate count
supported exercise(s) supported exercise(s)
Trainlog recording profile Trainlog recording profile
load semantics load semantics
primary muscles primary body zone (BODY_ZONES_V1)
secondary muscles secondary body zones (BODY_ZONES_V1)
``` ```
Important rule: Important rule:
@ -143,6 +153,9 @@ one photographed machine != one exercise
A single piece of equipment may support multiple exercises. Equipment and A single piece of equipment may support multiple exercises. Equipment and
exercise identity must remain distinct concepts. exercise identity must remain distinct concepts.
The primary/secondary body-zone dimension is already implemented by Body Zones
V1; gym inventory should link stable exercise identities to that model rather
than inventing another free-text body-region field.
Gate: Gate:
@ -155,19 +168,18 @@ GYM_CATALOG_V1=PASS
After the real gym inventory is normalized, enrich the exercise catalog with After the real gym inventory is normalized, enrich the exercise catalog with
structured metadata needed by planning and analytics. structured metadata needed by planning and analytics.
Candidate metadata: Remaining candidate metadata:
```text ```text
equipment equipment
primary_muscles
secondary_muscles
movement_family movement_family
body_region
laterality laterality
``` ```
The final schema must be designed before implementation. Do not encode these The final schema must be designed before implementation. Do not encode these
concepts into names or free-form notes as a substitute for a real model. concepts into names or free-form notes as a substitute for a real model.
Body-zone metadata is no longer future scope here; its frozen V1 taxonomy and
direct relations must be reused.
Gate: Gate:
@ -191,6 +203,12 @@ A planned session should allow:
Android should then open a prepared session and require only actual performance Android should then open a prepared session and require only actual performance
entry during training. entry during training.
The existing Body Zones V1 read APIs can later list exercises by direct or
descendant zone, optionally primary-only, and compose those IDs with existing
latest MAX and performance history. A future generator may use that surface,
but this planner tranche must still define its own selection policy and must not
duplicate MAX/history or infer a load automatically.
Target workflow: Target workflow:
```text ```text
@ -271,8 +289,9 @@ Candidate metrics:
- useful volume metrics where semantically valid; - useful volume metrics where semantically valid;
- progression by exercise. - progression by exercise.
Once exercise metadata exists, aggregate by muscle group or movement family to Use the existing Body Zones V1 read surface to aggregate by canonical body zone
help evaluate training balance. and hierarchy when evaluating training balance. A separate movement-family
taxonomy, if ever needed, must be versioned rather than inferred from names.
Gate: Gate:

View file

@ -17,6 +17,8 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS
EQUIPMENT_DEFINITIONS_V1=PASS EQUIPMENT_DEFINITIONS_V1=PASS
EXERCISE_RECONCILIATION_V2=PASS EXERCISE_RECONCILIATION_V2=PASS
EXPLICIT_MAX_RESULTS_V2=PASS EXPLICIT_MAX_RESULTS_V2=PASS
BODY_ZONE_SYNC_V1=PASS
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
``` ```
@ -45,10 +47,12 @@ Framework folder grant.
| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) | | Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) |
| Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | | Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| Android -> PC | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 | | PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 |
| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | | PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
| PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 | | PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 |
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 | | PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
@ -58,7 +62,9 @@ Android scoped storage can preserve a prior MTP-created object and create a
new artifact with the provider collision suffix, for example new artifact with the provider collision suffix, for example
`trainlog-mobile-export-v2 (N).json` or `trainlog-mobile-export-v2 (N).json` or
`trainlog-mobile-equipment-definitions-v1 (N).json`, or `trainlog-mobile-equipment-definitions-v1 (N).json`, or
`trainlog-equipment-associations-v2 (N).json`. For Android -> PC, the engine `trainlog-equipment-associations-v2 (N).json`, or
`trainlog-exercise-body-zones-v1 (N).json`, or
`trainlog-sync-request-v1 (N).json`. For Android -> PC, the engine
accepts only the canonical name and this exact suffix form, selects the newest accepts only the canonical name and this exact suffix form, selects the newest
MTP modification time (then the greatest suffix and a deterministic object-ID MTP modification time (then the greatest suffix and a deterministic object-ID
tie break), and validates that selected artifact normally. It never silently tie break), and validates that selected artifact normally. It never silently
@ -101,6 +107,43 @@ the supplied equipment manifest cannot appear in this artifact. These rules
preserve the definition identity that V2 equipment associations reference; preserve the definition identity that V2 equipment associations reference;
they do not change either V2 shape. they do not change either V2 shape.
## 4A. Exercise body zones V1
`trainlog-exercise-body-zones-v1.json` is the sole body-zone exchange source
and uses the same filename and format in both directions. Taxonomy definitions
are not copied into each exchange; both applications validate stable IDs from
`catalog/body-zones-v1.json`.
The strict root contains exactly:
```text
format = trainlog-exercise-body-zones
version = 1
generated_at
exercises[]
```
Each exercise row contains exactly:
```text
exercise_id
primary_zone_id canonical assignable ID or null
secondary_zone_ids ordered distinct canonical assignable IDs
```
`exercise_id` must be the stable lowercase `ex_<uuid-v4>` creator identity and
`generated_at` must carry an explicit UTC offset. Group IDs are rejected as
direct relations. An empty primary and empty list is the explicit unclassified
state; secondary relations without a primary are invalid. Equal state is an
idempotent skip. Successful publication records that exact snapshot as the
publisher's last shared baseline, including for a newly created custom
exercise. When only one side differs from that baseline, the complete incoming
or local state wins explicitly. If both sides differ, import reports a conflict
and rolls back; secondary lists are never unioned because that would invent
user intent. A mobile creator ID already reconciled by the immediately
preceding V2 exercise import is accepted only with that retained V2 definition
as proof, never from a name-only guess.
## 5. Android -> PC mobile snapshot ## 5. Android -> PC mobile snapshot
Header: Header:
@ -120,9 +163,10 @@ sessions
body_observations body_observations
``` ```
Android captures its custom-definition V1 companion, this V2 snapshot, and Android captures its custom-definition V1 companion, this V2 snapshot, body
the equipment-association companion before it publishes any of them. It then zones, and the equipment-association companion before it publishes any of
publishes in that order: definitions, V2 snapshot, associations. A malformed them. It then publishes in that order: definitions, V2 snapshot, body zones,
associations. A malformed
persisted custom definition aborts publication before a V2 file can advertise persisted custom definition aborts publication before a V2 file can advertise
its reference; bundled manifest equipment is never copied into definitions V1. its reference; bundled manifest equipment is never copied into definitions V1.
@ -371,9 +415,9 @@ Android request
The engine has three explicit modes: The engine has three explicit modes:
```text ```text
a Android -> PC: definition V1 -> mobile V2 -> association V2; no publish a Android -> PC: definition V1 -> mobile V2 -> body zones V1 -> association V2; no publish
p PC -> Android: definition V1 -> catalog V1 -> mobile V2 (including bodies) p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V2
-> association V2; no receive (including bodies) -> association V2; no receive
b bidirectional: complete inbound sequence, then complete outbound sequence b bidirectional: complete inbound sequence, then complete outbound sequence
``` ```
@ -384,7 +428,7 @@ reporting, and structured history for the work it performs.
## 11. Conflict reporting and preservation ## 11. Conflict reporting and preservation
Synchronization does not silently overwrite a session, body observation, Synchronization does not silently overwrite a session, body observation,
equipment association, or equipment definition when stable-identity content equipment association, body-zone relation, or equipment definition when stable-identity content
conflicts. The diagnostic identifies the affected stable identity and its conflicts. The diagnostic identifies the affected stable identity and its
source artifact/direction, then records a concise source summary in the run source artifact/direction, then records a concise source summary in the run
history. The conflicting persisted value remains preserved; resolution is an history. The conflicting persisted value remains preserved; resolution is an
@ -533,17 +577,22 @@ The PC-to-Android idempotence regression additionally feeds artifacts from all
four production PC exporters into the production Android repository importers. four production PC exporters into the production Android repository importers.
Its first pass imports the missing fixture data; its second and third passes Its first pass imports the missing fixture data; its second and third passes
report zero session, exercise, body-observation, and equipment additions and report zero session, exercise, body-observation, and equipment additions and
leave exact snapshots of every Android business table unchanged. On the real leave exact snapshots of every Android business table unchanged. On the
device, `install -r` of the validated APK preserved the backed-up database hash, Samsung SM-G990B, the Body Zones APK certificate matched the installed package
but the requested two live MTP runs remain unexecuted because the sandbox still and established keystore before `adb install -r`. The installed database then
fails `libusb_open()` before opening device storage. migrated v9 -> v10 without changing any pre-existing application row. Two live
bidirectional runs exchanged `trainlog-exercise-body-zones-v1.json`; the second
reported no additions/reconciliations, every Android application table stayed
equal to the first pass, and both peers retained the same 32 relations for 20
of 23 stable exercise IDs. The final companion states were semantically equal
between passes and the three explicit unclassified states remained intact.
## 18. Audited protocol limitations ## 18. Audited protocol limitations
Each mutating importer validates strictly and owns a SQLite transaction. The Each mutating importer validates strictly and owns a SQLite transaction. The
V2 association companion only corroborates equipment already imported in the V2 association companion only corroborates equipment already imported in the
mobile snapshot and refuses divergent state. A complete mobile snapshot and refuses divergent state. A complete
definitions/mobile/associations batch nevertheless has no common generation ID definitions/mobile/body-zones/associations batch nevertheless has no common generation ID
or cross-file transaction. Independent “newest artifact” selection can or cross-file transaction. Independent “newest artifact” selection can
therefore observe a partially published generation; validation stops on a therefore observe a partially published generation; validation stops on a
mismatch, but an earlier artifact may already have committed. Replay is mismatch, but an earlier artifact may already have committed. Replay is
@ -551,7 +600,7 @@ idempotent and no conflicting local value is overwritten. A future atomic-batch
design requires a new versioned manifest rather than a semantic change to any design requires a new versioned manifest rather than a semantic change to any
published format. published format.
Snapshots carry no exercise, session, body-observation, or equipment-definition Snapshots carry no exercise, session, body-observation, body-zone-relation, or equipment-definition
tombstones. Omission therefore never deletes one of those objects. The only tombstones. Omission therefore never deletes one of those objects. The only
explicit removal operation is association V2 `state: cleared`, targeted to one explicit removal operation is association V2 `state: cleared`, targeted to one
`(session_id, entry_id)`. `(session_id, entry_id)`.

View file

@ -69,6 +69,7 @@ not contractual):
database database
catalog catalog
equipment_catalog equipment_catalog
body_zones
custom_equipment custom_equipment
session_detail session_detail
duration duration
@ -93,6 +94,8 @@ mobile_import_multi_occurrence
equipment_associations_exchange equipment_associations_exchange
equipment_definitions_exchange equipment_definitions_exchange
exercise_reconciliation exercise_reconciliation
body_zone_sync
body_zone_catalog_validation
sync_direction sync_direction
sync_history sync_history
sync_screen_action sync_screen_action
@ -107,7 +110,7 @@ tui_workflows
Validated current suite: Validated current suite:
```text ```text
36/36 Meson tests PASS 39/39 Meson tests PASS
``` ```
The desktop executable is additionally smoke-checked in isolated tmux PTYs at The desktop executable is additionally smoke-checked in isolated tmux PTYs at
@ -125,12 +128,32 @@ Notable regression coverage:
- table-only desktop SETS workflow: planning does not create actual rows, - table-only desktop SETS workflow: planning does not create actual rows,
explicit add requires an actual metric, and normal zero-row completion is explicit add requires an actual metric, and normal zero-row completion is
rejected; Android legacy compact-draft decoding remains separately covered; rejected; Android legacy compact-draft decoding remains separately covered;
- direct v4 -> v7 database migration and v7 -> v8 custom-equipment migration; - direct v4 -> current database migration and v7 -> v8 custom-equipment migration;
- bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation; - bounded v8 -> v9 explicit-max migration, including ambiguous-attempt preservation;
- lossless v9 -> v10 `performed_sets` rebuild: historic NULL and positive - lossless v9 -> v10 `performed_sets` rebuild: historic NULL and positive
weights/IDs/owners/positions/metrics survive, explicit zero is accepted, and weights/IDs/owners/positions/metrics survive, explicit zero is accepted, and
injected failure rolls back with integrity, foreign-key and enforcement injected failure rolls back with integrity, foreign-key and enforcement
checks; checks;
- additive desktop v10 -> v11 and Android v9 -> v10 body-zone migrations:
exact stable row identities and history remain unchanged while only proven
manifest mappings are seeded; the Android fixture also preserves an active
draft, per-set loads, explicit MAX, a body observation and a custom equipment
definition;
- canonical body-zone validation rejects duplicate IDs/order, missing parents,
cycles, group/leaf disagreement, a taxonomy other than the exact V1 manifest,
invalid `full_body`, boolean versions, malformed stable IDs, and direct group
assignment;
- one primary/multiple secondary persistence, duplicate/unknown/group/orphan-
secondary rejection, primary/secondary exclusivity, edit, unclassified and
reopen;
- exact and parent-descendant zone filters, lower-body leaf coverage,
primary-only participation, normalized-prefix composition and custom rows;
- body-zone companion export/import, empty mapping, identical replay,
one-sided update, simultaneous conflict rollback and source-V2-proven
exercise-ID reconciliation without name-only inference; custom-exercise
publication establishes both peer baselines before a reverse one-sided edit;
malformed `ex_<uuid-v4>` identities and timestamps without offsets are
rejected;
- heterogeneous mobile-set import; - heterogeneous mobile-set import;
- per-set load persistence and correction: ordered rows retain mixed actual - per-set load persistence and correction: ordered rows retain mixed actual
repetitions, nullable loads, positions and assistance semantics through repetitions, nullable loads, positions and assistance semantics through
@ -214,6 +237,9 @@ Android repository host tests additionally cover exercise editing:
- completed history and active-draft references resolve the renamed catalog row; - completed history and active-draft references resolve the renamed catalog row;
- a referenced profile change is explicitly rejected; - a referenced profile change is explicitly rejected;
- same-ID PC-catalog rename reconciles in place without a duplicate. - same-ID PC-catalog rename reconciles in place without a duplicate.
- canonical body-zone taxonomy, create/edit/reopen, parent filters,
search+filter, unclassified rows, companion replay/update/conflict and
zone-safe exercise-identity merging.
## 7. Hardware MTP validation ## 7. Hardware MTP validation
@ -230,7 +256,7 @@ trainlog-mtp-roundtrip-probe
trainlog-mtp-mobile-export-probe trainlog-mtp-mobile-export-probe
``` ```
Current physical baseline: Previously established physical baseline:
```text ```text
USB_MTP_DETECTION=PASS USB_MTP_DETECTION=PASS
@ -303,7 +329,7 @@ When Android changed, add:
```bash ```bash
cd android cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug
``` ```
Documentation must describe the resulting state, not retain contradictory old Documentation must describe the resulting state, not retain contradictory old
@ -332,7 +358,7 @@ Coverage proves:
Validated current normal suite: Validated current normal suite:
```text ```text
36/36 Meson tests PASS 39/39 Meson tests PASS
``` ```
## 12. Body analytics regression ## 12. Body analytics regression
@ -357,13 +383,13 @@ Coverage includes:
Validated current normal suite: Validated current normal suite:
```text ```text
36/36 Meson tests PASS 39/39 Meson tests PASS
``` ```
## 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 v9 without clearing completed history or the draft. The chain reaches schema v10 without clearing completed history or the draft. The
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
includes exercise includes exercise
shapes and raw partial text, fresh repository restore, remove/discard, atomic shapes and raw partial text, fresh repository restore, remove/discard, atomic
@ -376,6 +402,10 @@ explicit max creation/edit/finalization without sets, distinct movement values
on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume on the same equipment, latest-per-exercise history, V2 replay, stable-ID resume
and bounded conversion that leaves multiple legacy attempts untouched. and bounded conversion that leaves multiple legacy attempts untouched.
Schema-v10 host coverage adds the canonical body-zone asset, stable-ID mapping,
relation constraints, filters, transactional editing and companion conflict
policy without changing explicit MAX or per-set tables.
The current set-row-editor coverage additionally proves that row edits, The current set-row-editor coverage additionally proves that row edits,
deletion and append preserve neighbouring rows; French-comma loads, blank deletion and append preserve neighbouring rows; French-comma loads, blank
loads and explicit zero loads remain distinct; reopening preserves aligned raw loads and explicit zero loads remain distinct; reopening preserves aligned raw
@ -400,10 +430,31 @@ V2 regression: mobile_import_variable_sets and the desktop/Android
Sanitizers: clang ASan/UBSan Meson build and test invocation Sanitizers: clang ASan/UBSan Meson build and test invocation
``` ```
Executed postrepair evidence is: 37 Android unit tests, `assembleDebug`, 36/36 Executed Body Zones V1 evidence is recorded after each closeout run: Android
Meson tests, valid and invalid JSON checks, import-contract 6/6, and 14/14 `testDebugUnitTest` 44/44 with the retained real v9 fixture enabled and
ASan/UBSan Meson tests. No device, installation, real-store migration, or `assembleDebug`, 39/39 Meson tests, valid and invalid JSON checks,
instrumentation execution is asserted by this checkpoint. import-contract checks, and the ASan/UBSan Meson suite. Device installation and
installed Android-store migration remain explicit hardware steps and are never
inferred from host tests.
The 2026-09-09 desktop closeout additionally backed up the real v10 database,
opened it through the production Notcurses binary, and verified v11 integrity,
foreign keys, all historical row counts and bidirectional row equality against
the backup. A real Kitty terminal validation exercised the zone list, parent
filter, classified/unclassified details and edit preloading/cancellation. The
Samsung SM-G990B then passed certificate matching, `adb install -r`, the real
v9 -> v10 migration, SQLite integrity/FK checks, row equality for every
pre-existing application table, the Android zone UI matrix and two live MTP
round trips. The second run reported zero additions/reconciliations and left
the Android application tables and semantic companion exercise states equal to
the first run. A regression also covers a current
`trainlog-sync-request-v1 (N).json` beside an older canonical request.
The optional `RealAndroidV9BodyZonesMigrationTest` is enabled by setting
`TRAINLOG_ANDROID_V9_FIXTURE` to a coherent copied v9 database. It makes two
test-owned copies before opening the production repository, then compares all
16 pre-existing application tables in both directions; it never opens or
modifies the supplied fixture through the migration helper.
```bash ```bash
cd android cd android

View file

@ -95,6 +95,20 @@ Catalog identities are stable.
Unicode-aware normalized-name uniqueness prevents duplicate logical names. Unicode-aware normalized-name uniqueness prevents duplicate logical names.
`3 Exercices` also owns Body Zones V1. Creation opens a keyboard-only manifest
selector: `p` chooses the sole primary, Space toggles secondaries, `n` selects
the explicit unclassified state and clears all relations, Enter validates and Escape
cancels without mutation. Only French display names are shown; stable IDs such
as `chest` remain internal. New set-based exercises require a primary zone.
The exercise list supports `/` prefix search and `z` cycling through all
manifest zones plus **Non renseignés**; `x` clears search and zone filter. Parent filters
include descendants, so **Membres supérieurs** finds direct chest/back/
shoulders/arms relations without stored parent rows. Search and filter combine.
Exercise detail displays the primary, ordered secondaries and the primary's
derived group. `e` edits name and zones transactionally; Escape at either edit
prompt leaves the durable row unchanged.
## 5. Session entry ## 5. Session entry
The TUI can record sessions directly. The TUI can record sessions directly.
@ -257,10 +271,10 @@ b run bidirectional synchronization
r refresh device status r refresh device status
``` ```
`a` imports definitions V1, mobile V2, and associations V2 only. `p` publishes `a` imports definitions V1, mobile V2, the body-zone companion and associations
definitions V1, catalog V1, mobile V2 (including body observations), and V2 only. `p` publishes definitions V1, catalog V1, the body-zone companion,
associations V2 only. `b` completes that inbound sequence before beginning the mobile V2 (including body observations), and associations V2 only. `b`
outbound sequence. completes that inbound sequence before beginning the outbound sequence.
The direction keys are direct actions: pressing `a`, `p`, or `b` opens one The direction keys are direct actions: pressing `a`, `p`, or `b` opens one
confirmation for that exact direction; there is no separate mode-selection confirmation for that exact direction; there is no separate mode-selection
@ -355,7 +369,7 @@ meson test -C build --print-errorlogs
Validated current normal suite: Validated current normal suite:
```text ```text
36/36 Meson tests PASS 39/39 Meson tests PASS
``` ```
## 16. Measured max view ## 16. Measured max view

View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Negative regressions for the canonical body-zone manifest contract."""
import copy
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
from validate_json import TrainlogSemanticError, validate_body_zone_catalog # noqa: E402
def rejected(document):
try:
validate_body_zone_catalog(document)
except TrainlogSemanticError:
return
raise AssertionError("invalid body-zone manifest was accepted")
def main():
source = json.loads((ROOT / "catalog/body-zones-v1.json").read_text(encoding="utf-8"))
validate_body_zone_catalog(source)
expected_v1 = [
("full_body", "Corps entier", None, 0, "standalone"),
("upper_body", "Membres supérieurs", None, 10, "group"),
("chest", "Pectoraux", "upper_body", 11, "leaf"),
("back", "Dos", "upper_body", 12, "leaf"),
("shoulders", "Épaules", "upper_body", 13, "leaf"),
("arms", "Bras", "upper_body", 14, "leaf"),
("core", "Abdominaux / tronc", None, 20, "standalone"),
("lower_body", "Membres inférieurs", None, 30, "group"),
("glutes", "Fessiers", "lower_body", 31, "leaf"),
("thighs", "Cuisses", "lower_body", 32, "leaf"),
("calves", "Mollets", "lower_body", 33, "leaf"),
]
assert [
(zone["zone_id"], zone["display_name"], zone["parent_zone_id"],
zone["sort_order"], zone["kind"])
for zone in source["zones"]
] == expected_v1
assert "cardio" not in {zone["zone_id"] for zone in source["zones"]}
duplicate_id = copy.deepcopy(source)
duplicate_id["zones"][1]["zone_id"] = duplicate_id["zones"][0]["zone_id"]
rejected(duplicate_id)
duplicate_order = copy.deepcopy(source)
duplicate_order["zones"][1]["sort_order"] = duplicate_order["zones"][0]["sort_order"]
rejected(duplicate_order)
missing_parent = copy.deepcopy(source)
missing_parent["zones"][2]["parent_zone_id"] = "missing"
rejected(missing_parent)
cycle = copy.deepcopy(source)
cycle["zones"][1]["parent_zone_id"] = "chest"
rejected(cycle)
wrong_kind = copy.deepcopy(source)
wrong_kind["zones"][1]["kind"] = "leaf"
rejected(wrong_kind)
derived_group_relation = copy.deepcopy(source)
derived_group_relation["exercise_mappings"][0]["primary_zone_id"] = "upper_body"
rejected(derived_group_relation)
full_body_child = copy.deepcopy(source)
full_body_child["zones"][0]["parent_zone_id"] = "upper_body"
rejected(full_body_child)
boolean_version = copy.deepcopy(source)
boolean_version["version"] = True
rejected(boolean_version)
invalid_zone_id = copy.deepcopy(source)
invalid_zone_id["zones"][2]["zone_id"] = "Chest libre"
rejected(invalid_zone_id)
invalid_exercise_id = copy.deepcopy(source)
invalid_exercise_id["exercise_mappings"][0]["exercise_id"] = "ex_not-a-uuid"
rejected(invalid_exercise_id)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Desktop body-zone companion replay, one-sided update and conflict regression."""
import json
import sqlite3
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
EXPORT = ROOT / "tools/export_exercise_body_zones.py"
IMPORT = ROOT / "tools/import_exercise_body_zones.py"
EXERCISE_ID = "ex_11111111-1111-4111-8111-111111111111"
def database(path, exercise_id=EXERCISE_ID, with_mapping=True, with_baseline=True):
connection = sqlite3.connect(path)
connection.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE exercises(
id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,
recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);
CREATE TABLE exercise_body_zones(
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
zone_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN('primary','secondary')),
PRIMARY KEY(exercise_row_id,zone_id));
CREATE UNIQUE INDEX exercise_body_zones_one_primary
ON exercise_body_zones(exercise_row_id) WHERE role='primary';
CREATE TABLE exercise_body_zone_sync(
exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE,
synced_state TEXT NOT NULL);
"""
)
connection.execute(
"INSERT INTO exercises VALUES(1,?,'Alias exercise','alias exercise','sets','reps',0)",
(exercise_id,),
)
if with_mapping:
connection.executescript("""
INSERT INTO exercise_body_zones VALUES(1,'chest','primary');
INSERT INTO exercise_body_zones VALUES(1,'arms','secondary');
""")
if with_baseline:
connection.execute(
"INSERT INTO exercise_body_zone_sync VALUES(1,'chest|arms')"
)
connection.execute("PRAGMA user_version=11")
connection.commit()
connection.close()
def run(script, artifact, db, *extra):
result = subprocess.run(
["python3", str(script), str(artifact), "--database", str(db), *map(str, extra)],
cwd=ROOT, text=True, capture_output=True,
)
return result
def mapping(db):
connection = sqlite3.connect(db)
rows = connection.execute(
"SELECT zone_id,role FROM exercise_body_zones ORDER BY role,zone_id",
).fetchall()
baseline = connection.execute("SELECT synced_state FROM exercise_body_zone_sync").fetchone()[0]
connection.close()
return rows, baseline
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary:
root = Path(temporary)
source = root / "source.db"
target = root / "target.db"
artifact = root / "trainlog-exercise-body-zones-v1.json"
database(source)
database(target)
exported = run(EXPORT, artifact, source)
assert exported.returncode == 0, exported.stdout + exported.stderr
replay = run(IMPORT, artifact, target)
assert replay.returncode == 0 and "zones_skipped=1" in replay.stdout, replay.stdout
payload = json.loads(artifact.read_text(encoding="utf-8"))
payload["exercises"][0]["primary_zone_id"] = "shoulders"
artifact.write_text(json.dumps(payload), encoding="utf-8")
update = run(IMPORT, artifact, target)
assert update.returncode == 0 and "zones_updated=1" in update.stdout, update.stdout
assert mapping(target) == ([('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms")
connection = sqlite3.connect(target)
connection.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=1")
connection.execute("INSERT INTO exercise_body_zones VALUES(1,'back','primary')")
connection.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')")
connection.commit()
connection.close()
payload["exercises"][0]["primary_zone_id"] = "chest"
artifact.write_text(json.dumps(payload), encoding="utf-8")
conflict = run(IMPORT, artifact, target)
assert conflict.returncode == 1
assert f"conflit zones simultané: {EXERCISE_ID}" in conflict.stdout
assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms")
orphan = json.loads(artifact.read_text(encoding="utf-8"))
orphan["exercises"][0]["primary_zone_id"] = None
orphan["exercises"][0]["secondary_zone_ids"] = ["arms"]
artifact.write_text(json.dumps(orphan), encoding="utf-8")
rejected_orphan = run(IMPORT, artifact, target)
assert rejected_orphan.returncode == 1
assert "secondaires sans zone principale" in rejected_orphan.stdout
assert mapping(target) == ([('back', 'primary'), ('arms', 'secondary')], "shoulders|arms")
invalid_identity = json.loads(artifact.read_text(encoding="utf-8"))
invalid_identity["exercises"][0]["primary_zone_id"] = "back"
invalid_identity["exercises"][0]["secondary_zone_ids"] = []
invalid_identity["exercises"][0]["exercise_id"] = "ex_not-a-uuid"
artifact.write_text(json.dumps(invalid_identity), encoding="utf-8")
assert run(IMPORT, artifact, target).returncode == 1
invalid_time = json.loads(json.dumps(invalid_identity))
invalid_time["exercises"][0]["exercise_id"] = EXERCISE_ID
invalid_time["generated_at"] = "2032-01-01T00:00:00"
artifact.write_text(json.dumps(invalid_time), encoding="utf-8")
invalid_time_result = run(IMPORT, artifact, target)
assert invalid_time_result.returncode == 1
assert "sans offset" in invalid_time_result.stdout
corrupt_db = sqlite3.connect(target)
corrupt_db.execute(
"DELETE FROM exercise_body_zones WHERE role='primary'"
)
corrupt_db.commit()
corrupt_db.close()
rejected_export = run(EXPORT, artifact, target)
assert rejected_export.returncode == 1
assert "relations de zones SQLite invalides" in rejected_export.stdout
corrupt_db = sqlite3.connect(target)
corrupt_db.execute(
"INSERT INTO exercise_body_zones VALUES(1,'back','primary')"
)
corrupt_db.commit()
corrupt_db.close()
payload["exercises"][0]["primary_zone_id"] = None
payload["exercises"][0]["secondary_zone_ids"] = []
artifact.write_text(json.dumps(payload), encoding="utf-8")
connection = sqlite3.connect(source)
connection.execute("DELETE FROM exercise_body_zones")
connection.execute("UPDATE exercise_body_zone_sync SET synced_state='|'")
connection.commit(); connection.close()
no_mapping = run(IMPORT, artifact, source)
assert no_mapping.returncode == 0 and "zones_skipped=1" in no_mapping.stdout
# import_mobile_export may have safely coalesced a remote creator ID
# into the normalized local identity before this companion arrives.
# The retained source V2 definition is the required proof; name alone
# is never accepted by the zone importer.
alias_db = root / "alias.db"
local_id = "ex_22222222-2222-4222-8222-222222222222"
remote_id = "ex_33333333-3333-4333-8333-333333333333"
database(alias_db, local_id, with_mapping=False)
alias_artifact = root / "alias-zones.json"
alias_artifact.write_text(json.dumps({
"format": "trainlog-exercise-body-zones", "version": 1,
"generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{
"exercise_id": remote_id, "primary_zone_id": "back",
"secondary_zone_ids": ["arms"],
}],
}), encoding="utf-8")
proof = root / "proof.json"
proof.write_text(json.dumps({
"format": "trainlog-mobile-export", "version": 2,
"generated_at": "2032-01-01T00:00:00+00:00", "exercises": [{
"exercise_id": remote_id, "name": "Alias exercise",
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
}], "sessions": [], "body_observations": [],
}), encoding="utf-8")
alias_import = run(
IMPORT, alias_artifact, alias_db, "--mobile-export", proof,
)
assert alias_import.returncode == 0, alias_import.stdout + alias_import.stderr
assert mapping(alias_db) == ([('back', 'primary'), ('arms', 'secondary')], "back|arms")
# A custom exercise starts with no baseline on its creator. Publishing
# the exact snapshot acknowledges it locally; a later peer-only edit
# must then flow back instead of becoming a false simultaneous conflict.
creator = root / "creator.db"
peer = root / "peer.db"
shared = root / "shared.json"
database(creator, with_mapping=True, with_baseline=False)
database(peer, with_mapping=False)
assert run(EXPORT, shared, creator).returncode == 0
assert run(IMPORT, shared, creator).returncode == 0 # publication ack
assert run(IMPORT, shared, peer).returncode == 0
peer_db = sqlite3.connect(peer)
peer_db.execute("DELETE FROM exercise_body_zones")
peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'shoulders','primary')")
peer_db.execute("INSERT INTO exercise_body_zones VALUES(1,'arms','secondary')")
peer_db.commit()
peer_db.close()
assert run(EXPORT, shared, peer).returncode == 0
assert run(IMPORT, shared, peer).returncode == 0 # publication ack
returned = run(IMPORT, shared, creator)
assert returned.returncode == 0 and "zones_updated=1" in returned.stdout
assert mapping(creator) == (
[('shoulders', 'primary'), ('arms', 'secondary')], "shoulders|arms"
)
if __name__ == "__main__":
main()

View file

@ -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): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
raise ValueError("schema desktop v8, v9 ou v10 requis") raise ValueError("schema desktop v8 à v11 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"))

View file

@ -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): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
raise ValueError("schema desktop v8, v9 ou v10 requis") raise ValueError("schema desktop v8 à v11 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")]

View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Export the sole bidirectional exercise/body-zone companion v1."""
import argparse
import json
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "catalog/body-zones-v1.json"
EXERCISE_ID_PATTERN = 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}"
)
def assignable_zone_ids():
root = json.loads(CATALOG.read_text(encoding="utf-8"))
if root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1 or \
not isinstance(root.get("zones"), list):
raise ValueError("catalogue zones v1 invalide")
return {
item["zone_id"] for item in root["zones"]
if isinstance(item, dict) and item.get("kind") != "group"
}
def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
args = parser.parse_args()
assignable = assignable_zone_ids()
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
raise ValueError("schema desktop v11 requis")
exercises = []
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:
raise ValueError(f"exercise_id SQLite invalide: {exercise['exercise_id']}")
primary = connection.execute(
"SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'",
(exercise["id"],),
).fetchone()
secondary = [row[0] for row in connection.execute(
"SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' ORDER BY zone_id",
(exercise["id"],),
)]
direct = ([] if primary is None else [primary[0]]) + secondary
if any(zone_id not in assignable for zone_id in direct) or \
len(direct) != len(set(direct)) or \
(primary is None and secondary):
raise ValueError(
f"relations de zones SQLite invalides: {exercise['exercise_id']}"
)
exercises.append({
"exercise_id": exercise["exercise_id"],
"primary_zone_id": None if primary is None else primary[0],
"secondary_zone_ids": secondary,
})
payload = {
"format": "trainlog-exercise-body-zones",
"version": 1,
"generated_at": datetime.now().astimezone().isoformat(),
"exercises": exercises,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("EXERCISE_BODY_ZONES_EXPORT=PASS")
print(f"exercises={len(exercises)}")
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EXERCISE_BODY_ZONES_EXPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -64,7 +64,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): if version not in (8, 9, 10, 11):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -30,8 +30,11 @@ def main():
con = sqlite3.connect(args.database) con = sqlite3.connect(args.database)
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] != 10: # The V2 shape itself does not read body-zone tables. Accept the true
raise ValueError("schema desktop v10 requis") # immediately-previous v10 fixture while production v11 publishes the
# separate body-zone companion.
if con.execute("PRAGMA user_version").fetchone()[0] not in (10, 11):
raise ValueError("schema desktop v10 ou v11 requis")
known_equipment = supplied_equipment_ids() known_equipment = supplied_equipment_ids()
known_equipment.update(row[0] for row in con.execute( known_equipment.update(row[0] for row in con.execute(
"SELECT equipment_id FROM custom_equipment")) "SELECT equipment_id FROM custom_equipment"))

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Validate body-zones-v1 and generate its bounded C representation."""
import json
import re
import sys
source, output = sys.argv[1:]
with open(source, encoding="utf-8") as handle:
root = json.load(handle)
if set(root) != {"format", "version", "zones", "exercise_mappings"} or \
root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1 or \
isinstance(root.get("version"), bool):
raise SystemExit("unsupported body-zone catalogue")
zones = root.get("zones")
if not isinstance(zones, list) or not zones:
raise SystemExit("zones must be a non-empty list")
zone_ids = set()
orders = set()
by_id = {}
for zone in zones:
if set(zone) != {"zone_id", "display_name", "parent_zone_id", "sort_order", "kind"}:
raise SystemExit("invalid body-zone entry shape")
zone_id = zone["zone_id"]
if not isinstance(zone_id, str) or not re.fullmatch(r"[a-z][a-z0-9_]*", zone_id):
raise SystemExit("invalid body-zone id")
if zone_id in zone_ids or zone["sort_order"] in orders:
raise SystemExit("duplicate body-zone id or sort_order")
if not isinstance(zone["display_name"], str) or not zone["display_name"].strip():
raise SystemExit("invalid body-zone display_name")
if not isinstance(zone["sort_order"], int) or isinstance(zone["sort_order"], bool) or zone["sort_order"] < 0:
raise SystemExit("invalid body-zone sort_order")
if zone["kind"] not in {"group", "leaf", "standalone"}:
raise SystemExit("invalid body-zone kind")
zone_ids.add(zone_id)
orders.add(zone["sort_order"])
by_id[zone_id] = zone
for zone in zones:
parent = zone["parent_zone_id"]
if parent is not None and parent not in zone_ids:
raise SystemExit("body-zone parent does not exist")
seen = {zone["zone_id"]}
while parent is not None:
if parent in seen:
raise SystemExit("body-zone hierarchy contains a cycle")
seen.add(parent)
parent = by_id[parent]["parent_zone_id"]
children = [item for item in zones if item["parent_zone_id"] == zone["zone_id"]]
if (zone["kind"] == "group") != bool(children):
raise SystemExit("body-zone group/leaf declaration disagrees with hierarchy")
if by_id.get("full_body", {}).get("parent_zone_id") is not None or \
by_id.get("full_body", {}).get("kind") != "standalone":
raise SystemExit("full_body must be autonomous")
if by_id.get("upper_body", {}).get("kind") != "group" or by_id.get("lower_body", {}).get("kind") != "group":
raise SystemExit("upper_body and lower_body must be groups")
mappings = root["exercise_mappings"]
if not isinstance(mappings, list):
raise SystemExit("exercise_mappings must be a list")
seen_exercises = set()
for mapping in mappings:
if set(mapping) != {"exercise_id", "exercise_name", "primary_zone_id", "secondary_zone_ids", "decision_source"}:
raise SystemExit("invalid exercise body-zone mapping shape")
exercise_id = mapping["exercise_id"]
secondary = mapping["secondary_zone_ids"]
if not isinstance(exercise_id, str) or re.fullmatch(
r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
exercise_id,
) is None or exercise_id in seen_exercises:
raise SystemExit("duplicate or invalid mapped exercise_id")
if not isinstance(mapping["exercise_name"], str) or not mapping["exercise_name"].strip():
raise SystemExit("mapping lacks exercise name")
if not isinstance(mapping["primary_zone_id"], str) or \
mapping["primary_zone_id"] not in zone_ids or \
by_id[mapping["primary_zone_id"]]["kind"] == "group" or \
not isinstance(secondary, list):
raise SystemExit("mapping references unknown primary zone")
if any(not isinstance(item, str) for item in secondary) or \
len(secondary) != len(set(secondary)) or \
any(item not in zone_ids or by_id[item]["kind"] == "group" for item in secondary):
raise SystemExit("mapping references duplicate/unknown secondary zone")
if mapping["primary_zone_id"] in secondary:
raise SystemExit("mapping repeats primary as secondary")
if not isinstance(mapping["decision_source"], str) or not mapping["decision_source"].strip():
raise SystemExit("mapping lacks decision source")
seen_exercises.add(exercise_id)
def c(value):
return json.dumps(value, ensure_ascii=False)
with open(output, "w", encoding="utf-8") as generated:
generated.write('#include "trainlog/body_zone_catalog.h"\n#include <string.h>\n\n')
generated.write("static const TrainlogBodyZone zones[] = {\n")
for zone in sorted(zones, key=lambda item: item["sort_order"]):
parent = "NULL" if zone["parent_zone_id"] is None else c(zone["parent_zone_id"])
generated.write(" {%s, %s, %s, %d, %s},\n" % (
c(zone["zone_id"]), c(zone["display_name"]), parent,
zone["sort_order"], "true" if zone["kind"] == "group" else "false"))
generated.write("};\nstatic const TrainlogBodyZoneInitialMapping mappings[] = {\n")
for mapping in mappings:
generated.write(" {%s, %s, %s, %s, %s},\n" % (
c(mapping["exercise_id"]), c(mapping["exercise_name"]),
c(mapping["primary_zone_id"]), c("\n".join(mapping["secondary_zone_ids"])),
c(mapping["decision_source"])))
generated.write("};\n")
generated.write(r'''
size_t trainlog_body_zone_catalog_count(void) { return sizeof(zones) / sizeof(zones[0]); }
const TrainlogBodyZone *trainlog_body_zone_catalog_at(size_t index) {
return index < trainlog_body_zone_catalog_count() ? &zones[index] : NULL;
}
const TrainlogBodyZone *trainlog_body_zone_catalog_lookup(const char *zone_id) {
size_t index;
if (zone_id == NULL) return NULL;
for (index = 0; index < trainlog_body_zone_catalog_count(); ++index)
if (strcmp(zones[index].zone_id, zone_id) == 0) return &zones[index];
return NULL;
}
size_t trainlog_body_zone_catalog_children(const char *zone_id, const TrainlogBodyZone **output, size_t capacity) {
size_t index, count = 0;
if (zone_id == NULL || (capacity > 0 && output == NULL)) return 0;
for (index = 0; index < trainlog_body_zone_catalog_count(); ++index) {
if (zones[index].parent_zone_id != NULL && strcmp(zones[index].parent_zone_id, zone_id) == 0) {
if (count < capacity) output[count] = &zones[index];
++count;
}
}
return count;
}
size_t trainlog_body_zone_catalog_ancestors(const char *zone_id, const TrainlogBodyZone **output, size_t capacity) {
const TrainlogBodyZone *current = trainlog_body_zone_catalog_lookup(zone_id);
size_t count = 0;
if (current == NULL || (capacity > 0 && output == NULL)) return 0;
while (current->parent_zone_id != NULL) {
current = trainlog_body_zone_catalog_lookup(current->parent_zone_id);
if (current == NULL) return 0;
if (count < capacity) output[count] = current;
++count;
}
return count;
}
bool trainlog_body_zone_catalog_is_descendant(const char *zone_id, const char *ancestor_zone_id) {
const TrainlogBodyZone *current = trainlog_body_zone_catalog_lookup(zone_id);
if (current == NULL || ancestor_zone_id == NULL) return false;
while (current->parent_zone_id != NULL) {
if (strcmp(current->parent_zone_id, ancestor_zone_id) == 0) return true;
current = trainlog_body_zone_catalog_lookup(current->parent_zone_id);
if (current == NULL) return false;
}
return false;
}
size_t trainlog_body_zone_initial_mapping_count(void) { return sizeof(mappings) / sizeof(mappings[0]); }
const TrainlogBodyZoneInitialMapping *trainlog_body_zone_initial_mapping_at(size_t index) {
return index < trainlog_body_zone_initial_mapping_count() ? &mappings[index] : NULL;
}
''')

View file

@ -113,8 +113,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): if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
fail("schema desktop v8, v9 ou v10 requis") fail("schema desktop v8 à v11 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"))

View file

@ -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): if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
fail("schema desktop v8, v9 ou v10 requis") fail("schema desktop v8 à v11 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:

View file

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Atomically reconcile exercise/body-zone companion v1 with a sync baseline."""
import argparse
import json
import os
import re
import sqlite3
import unicodedata
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "catalog/body-zones-v1.json"
class ImportFailure(RuntimeError):
pass
EXERCISE_ID_PATTERN = 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}"
)
def default_database():
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
def exact(value, keys, label):
if not isinstance(value, dict) or set(value) != keys:
raise ImportFailure(f"{label}: forme invalide")
def load_catalog():
root = json.loads(CATALOG.read_text(encoding="utf-8"))
if root.get("format") != "trainlog-body-zone-catalog" or root.get("version") != 1:
raise ImportFailure("catalogue zones v1 invalide")
return {item["zone_id"]: item for item in root["zones"]}
def state(primary, secondary):
return (primary or "") + "|" + ",".join(sorted(secondary))
def current(connection, row_id):
primary = connection.execute(
"SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,),
).fetchone()
secondary = [row[0] for row in connection.execute(
"SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' ORDER BY zone_id",
(row_id,),
)]
return (None if primary is None else primary[0], secondary)
def normalize_name(value):
"""Match the desktop importer's stable normalized-name comparison."""
folded = unicodedata.normalize("NFC", value.casefold())
output = []
pending_space = False
wrote_content = False
for char in folded:
if char.isspace():
if wrote_content:
pending_space = True
continue
if pending_space:
output.append(" ")
pending_space = False
output.append(char)
wrote_content = True
normalized = "".join(output)
if not normalized:
raise ImportFailure("preuve mobile: nom d'exercice vide")
return normalized
def load_mobile_exercise_proof(path):
"""Load only the already-imported V2 definitions needed to prove aliases."""
payload = json.loads(path.read_text(encoding="utf-8"))
if payload.get("format") != "trainlog-mobile-export" or payload.get("version") != 2 or \
not isinstance(payload.get("exercises"), list):
raise ImportFailure("snapshot mobile V2 de preuve invalide")
proof = {}
for index, item in enumerate(payload["exercises"]):
if not isinstance(item, dict):
raise ImportFailure(f"preuve mobile exercises[{index}] invalide")
exercise_id = item.get("exercise_id")
name = item.get("name")
recording = item.get("recording_mode")
tracking = item.get("tracking_mode")
data_fields = item.get("data_fields")
if not isinstance(exercise_id, str) or not exercise_id or exercise_id in proof or \
not isinstance(name, str) or not name or recording not in {"sets", "continuous"} or \
tracking not in {"reps", "duration"} or isinstance(data_fields, bool) or \
not isinstance(data_fields, int) or data_fields < 0:
raise ImportFailure(f"preuve mobile exercises[{index}] invalide")
proof[exercise_id] = (normalize_name(name), recording, tracking, data_fields)
return proof
def resolve_exercise_row(connection, exercise_id, mobile_proof):
row = connection.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (exercise_id,),
).fetchone()
if row is not None:
return row[0]
proof = mobile_proof.get(exercise_id)
if proof is None:
raise ImportFailure(f"exercice inconnu: {exercise_id}")
normalized, recording, tracking, data_fields = proof
row = connection.execute(
"SELECT id,recording_mode,tracking_mode,data_fields FROM exercises "
"WHERE normalized_name=?", (normalized,),
).fetchone()
if row is None or row[1] != recording or row[2] != tracking or \
not ((row[3] & ~data_fields) == 0 or (data_fields & ~row[3]) == 0):
raise ImportFailure(f"preuve de réconciliation invalide: {exercise_id}")
# CONTRACT: import_mobile_export.py ran first in the same sync and removed
# only a profile-compatible duplicate. The source V2 definition proves that
# this now-absent creator ID resolves to the one normalized desktop row.
return row[0]
def replace(connection, row_id, primary, secondary):
connection.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=?", (row_id,))
if primary is not None:
connection.execute(
"INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (row_id, primary),
)
connection.executemany(
"INSERT INTO exercise_body_zones VALUES(?,?,'secondary')",
[(row_id, zone_id) for zone_id in secondary],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--database", type=Path, default=default_database())
parser.add_argument(
"--mobile-export",
type=Path,
help="snapshot V2 importé juste avant, utilisé seulement pour prouver un ID réconcilié",
)
args = parser.parse_args()
zones = load_catalog()
payload = json.loads(args.input.read_text(encoding="utf-8"))
exact(payload, {"format", "version", "generated_at", "exercises"}, "racine")
if payload["format"] != "trainlog-exercise-body-zones" or payload["version"] != 1 or \
isinstance(payload["version"], bool) or \
not isinstance(payload["generated_at"], str) or not payload["generated_at"] or \
not isinstance(payload["exercises"], list):
raise ImportFailure("companion zones v1 invalide")
try:
generated_at = datetime.fromisoformat(payload["generated_at"].replace("Z", "+00:00"))
except ValueError as error:
raise ImportFailure("generated_at invalide") from error
if generated_at.utcoffset() is None:
raise ImportFailure("generated_at sans offset")
parsed = []
seen = set()
for index, item in enumerate(payload["exercises"]):
exact(item, {"exercise_id", "primary_zone_id", "secondary_zone_ids"}, f"exercises[{index}]")
exercise_id = item["exercise_id"]
primary = item["primary_zone_id"]
secondary = item["secondary_zone_ids"]
if not isinstance(exercise_id, str) or EXERCISE_ID_PATTERN.fullmatch(exercise_id) is None or \
exercise_id in seen:
raise ImportFailure(f"exercises[{index}].exercise_id invalide")
if primary is not None and (not isinstance(primary, str) or primary not in zones):
raise ImportFailure(f"exercises[{index}].primary_zone_id invalide")
if not isinstance(secondary, list) or any(not isinstance(zone, str) or zone not in zones for zone in secondary):
raise ImportFailure(f"exercises[{index}].secondary_zone_ids invalide")
if len(secondary) != len(set(secondary)) or primary in secondary:
raise ImportFailure(f"exercises[{index}]: zones dupliquées")
if primary is None and secondary:
raise ImportFailure(f"exercises[{index}]: secondaires sans zone principale")
if any(zones[zone]["kind"] == "group" for zone in ([primary] if primary else []) + secondary):
raise ImportFailure(f"exercises[{index}]: relation parent dérivable interdite")
seen.add(exercise_id)
parsed.append((exercise_id, primary, sorted(secondary)))
proof_path = args.mobile_export
if proof_path is None:
candidate = args.input.with_name("trainlog-mobile-export-v2.json")
if candidate.exists():
proof_path = candidate
mobile_proof = load_mobile_exercise_proof(proof_path) if proof_path is not None else {}
connection = sqlite3.connect(args.database)
updated = skipped = kept_local = 0
resolved_rows = set()
try:
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
raise ImportFailure("schema desktop v11 requis")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
for exercise_id, primary, secondary in parsed:
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
if row_id in resolved_rows:
raise ImportFailure(f"deux identités entrantes résolvent le même exercice: {exercise_id}")
resolved_rows.add(row_id)
local_primary, local_secondary = current(connection, row_id)
local_state = state(local_primary, local_secondary)
incoming_state = state(primary, secondary)
baseline_row = connection.execute(
"SELECT synced_state FROM exercise_body_zone_sync WHERE exercise_row_id=?", (row_id,),
).fetchone()
baseline = None if baseline_row is None else baseline_row[0]
if local_state == incoming_state:
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
skipped += 1
elif baseline is not None and local_state == baseline:
replace(connection, row_id, primary, secondary)
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
updated += 1
elif baseline is not None and incoming_state == baseline:
kept_local += 1
elif baseline is None and local_state == "|":
replace(connection, row_id, primary, secondary)
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
updated += 1
else:
raise ImportFailure(f"conflit zones simultané: {exercise_id}")
connection.commit()
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
print(f"zones_updated={updated}")
print(f"zones_skipped={skipped}")
print(f"zones_kept_local={kept_local}")
except Exception:
connection.rollback()
raise
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EXERCISE_BODY_ZONES_IMPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -776,9 +776,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): if version not in (5, 6, 7, 8, 9, 10, 11):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v10 attendue, version trouvée: {version}" f"base desktop schema v5 à v11 attendue, version trouvée: {version}"
) )
@ -894,9 +894,49 @@ def enrich_desktop_profile(connection, row, exercise):
def merge_desktop_exercise_rows(connection, canonical, retired): def merge_desktop_exercise_rows(connection, canonical, retired):
"""Move the complete current desktop FK graph before deleting a duplicate.""" """Move the complete current desktop FK graph before deleting a duplicate."""
# WHY: the current v8 desktop schema has exactly one exercise-row FK owner. # WHY: schema v11 adds a second exercise-row owner. Keeping this operation
# Keeping this operation explicit makes a future schema addition fail its # explicit prevents identity reconciliation from silently dropping a body-
# reconciliation tests instead of silently leaving a dangling identity. # zone decision while occurrence IDs and all history remain unchanged.
has_body_zones = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='exercise_body_zones';"
).fetchone() is not None
if has_body_zones:
def zone_state(row_id):
rows = connection.execute(
"SELECT zone_id,role FROM exercise_body_zones "
"WHERE exercise_row_id=? ORDER BY role,zone_id;",
(row_id,),
).fetchall()
return tuple((row[0], row[1]) for row in rows)
canonical_state = zone_state(canonical["id"])
retired_state = zone_state(retired["id"])
if canonical_state and retired_state and canonical_state != retired_state:
raise ImportFailure(
"conflit zones pendant réconciliation des identités "
f"{canonical['exercise_id']} et {retired['exercise_id']}"
)
if not canonical_state and retired_state:
connection.execute(
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
(canonical["id"], retired["id"]),
)
else:
connection.execute(
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
(retired["id"],),
)
if connection.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='exercise_body_zone_sync';"
).fetchone() is not None:
# INVARIANT: a row merge is not a sync acknowledgement. Clearing
# both ancestors makes a later divergent companion conflict rather
# than treating one creator's stale baseline as shared truth.
connection.execute(
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
(canonical["id"], retired["id"]),
)
connection.execute( connection.execute(
"UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;", "UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
(canonical["id"], retired["id"]), (canonical["id"], retired["id"]),

View file

@ -6,6 +6,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import math import math
import re
import sys import sys
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime
@ -27,12 +28,85 @@ ROOT = Path(__file__).resolve().parents[1]
SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json" SCHEMA_PATH = ROOT / "format" / "trainlog-v1.schema.json"
VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid" VALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "valid"
INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid" INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
BODY_ZONE_CATALOG_PATH = ROOT / "catalog" / "body-zones-v1.json"
class TrainlogSemanticError(ValueError): class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates its format semantics.""" """Raised when structurally valid JSON violates its format semantics."""
def validate_body_zone_catalog(document: Any) -> None:
"""Validate the one canonical taxonomy, hierarchy and initial mappings."""
if not isinstance(document, dict) or set(document) != {
"format", "version", "zones", "exercise_mappings",
} or document.get("format") != "trainlog-body-zone-catalog" or \
document.get("version") != 1 or isinstance(document.get("version"), bool):
raise TrainlogSemanticError("body-zone catalog v1: invalid root")
zones = document["zones"]
mappings = document["exercise_mappings"]
if not isinstance(zones, list) or not zones:
raise TrainlogSemanticError("body-zone catalog v1: zones must be non-empty")
if not isinstance(mappings, list):
raise TrainlogSemanticError("body-zone catalog v1: exercise_mappings must be a list")
by_id: dict[str, dict[str, Any]] = {}
orders: set[int] = set()
for index, zone in enumerate(zones):
if not isinstance(zone, dict) or set(zone) != {
"zone_id", "display_name", "parent_zone_id", "sort_order", "kind",
}:
raise TrainlogSemanticError(f"zones[{index}]: invalid shape")
zone_id = zone["zone_id"]
order = zone["sort_order"]
if not isinstance(zone_id, str) or re.fullmatch(r"[a-z][a-z0-9_]*", zone_id) is None or \
zone_id in by_id:
raise TrainlogSemanticError(f"zones[{index}]: duplicate/invalid zone_id")
if not isinstance(order, int) or isinstance(order, bool) or order < 0 or order in orders:
raise TrainlogSemanticError(f"zones[{index}]: duplicate/invalid sort_order")
if not isinstance(zone["display_name"], str) or not zone["display_name"].strip() or \
zone["kind"] not in {"group", "leaf", "standalone"}:
raise TrainlogSemanticError(f"zones[{index}]: invalid metadata")
by_id[zone_id] = zone
orders.add(order)
for zone in zones:
parent = zone["parent_zone_id"]
seen = {zone["zone_id"]}
while parent is not None:
if parent not in by_id or parent in seen:
raise TrainlogSemanticError(f"zone {zone['zone_id']}: invalid/cyclic parent")
seen.add(parent)
parent = by_id[parent]["parent_zone_id"]
children = any(item["parent_zone_id"] == zone["zone_id"] for item in zones)
if (zone["kind"] == "group") != children:
raise TrainlogSemanticError(f"zone {zone['zone_id']}: kind disagrees with hierarchy")
if by_id.get("full_body", {}).get("parent_zone_id") is not None or \
by_id.get("full_body", {}).get("kind") != "standalone" or \
by_id.get("upper_body", {}).get("kind") != "group" or \
by_id.get("lower_body", {}).get("kind") != "group":
raise TrainlogSemanticError("body-zone special/group contract invalid")
seen_exercises: set[str] = set()
for index, mapping in enumerate(mappings):
if not isinstance(mapping, dict) or set(mapping) != {
"exercise_id", "exercise_name", "primary_zone_id", "secondary_zone_ids", "decision_source",
}:
raise TrainlogSemanticError(f"exercise_mappings[{index}]: invalid shape")
exercise_id = mapping["exercise_id"]
primary = mapping["primary_zone_id"]
secondary = mapping["secondary_zone_ids"]
if not isinstance(exercise_id, str) or re.fullmatch(
r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
exercise_id,
) is None or exercise_id in seen_exercises or \
not isinstance(mapping["exercise_name"], str) or not mapping["exercise_name"].strip() or \
not isinstance(primary, str) or primary not in by_id or by_id[primary]["kind"] == "group" or \
not isinstance(secondary, list) or \
any(not isinstance(item, str) or item not in by_id or by_id[item]["kind"] == "group"
for item in secondary) or len(secondary) != len(set(secondary)) or \
primary in secondary or not isinstance(mapping["decision_source"], str) or \
not mapping["decision_source"].strip():
raise TrainlogSemanticError(f"exercise_mappings[{index}]: invalid relation")
seen_exercises.add(exercise_id)
def load_json(path: Path) -> Any: def load_json(path: Path) -> Any:
"""Load one UTF-8 JSON file and return its decoded value.""" """Load one UTF-8 JSON file and return its decoded value."""
with path.open("r", encoding="utf-8") as handle: with path.open("r", encoding="utf-8") as handle:
@ -309,7 +383,14 @@ def validate_document(
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
return [str(exc)] return [str(exc)]
is_body_zones = isinstance(document, dict) and document.get("format") == "trainlog-body-zone-catalog"
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2 is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
if is_body_zones:
try:
validate_body_zone_catalog(document)
except TrainlogSemanticError as exc:
return [str(exc)]
return []
if not is_mobile_v2: if not is_mobile_v2:
errors = structural_errors(validator, document) errors = structural_errors(validator, document)
if errors: if errors:
@ -339,6 +420,15 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
valid, invalid = discover_suite() valid, invalid = discover_suite()
failed = False failed = False
body_zone_errors = validate_document(validator, BODY_ZONE_CATALOG_PATH)
if body_zone_errors:
print(f"FAIL body-zone catalog: {BODY_ZONE_CATALOG_PATH}")
for error in body_zone_errors:
print(f" {error}")
failed = True
else:
print(f"PASS body-zone catalog: {BODY_ZONE_CATALOG_PATH}")
if not valid: if not valid:
print("FAIL test suite: no valid fixtures found") print("FAIL test suite: no valid fixtures found")
return 1 return 1

View file

@ -0,0 +1,50 @@
#ifndef TRAINLOG_BODY_ZONE_CATALOG_H
#define TRAINLOG_BODY_ZONE_CATALOG_H
#include <stdbool.h>
#include <stddef.h>
/* CONTRACT: strings are generated from catalog/body-zones-v1.json and are
* borrowed for process lifetime. Stable zone_id values, never display names,
* cross persistence and synchronization boundaries. */
typedef struct TrainlogBodyZone {
const char *zone_id;
const char *display_name;
const char *parent_zone_id;
int sort_order;
bool is_group;
} TrainlogBodyZone;
typedef struct TrainlogBodyZoneInitialMapping {
const char *exercise_id;
const char *exercise_name;
const char *primary_zone_id;
const char *secondary_zone_ids; /* newline-separated canonical IDs */
const char *decision_source;
} TrainlogBodyZoneInitialMapping;
/* All returned catalogue pointers are borrowed and remain valid for process
* lifetime. A zero count from children/ancestors is either an empty relation
* or invalid input; callers can distinguish unknown IDs with lookup(). */
size_t trainlog_body_zone_catalog_count(void);
const TrainlogBodyZone *trainlog_body_zone_catalog_at(size_t index);
const TrainlogBodyZone *trainlog_body_zone_catalog_lookup(const char *zone_id);
size_t trainlog_body_zone_catalog_children(
const char *zone_id,
const TrainlogBodyZone **output,
size_t capacity
);
size_t trainlog_body_zone_catalog_ancestors(
const char *zone_id,
const TrainlogBodyZone **output,
size_t capacity
);
bool trainlog_body_zone_catalog_is_descendant(
const char *zone_id,
const char *ancestor_zone_id
);
/* Initial mappings are migration evidence, not a mutable runtime catalogue. */
size_t trainlog_body_zone_initial_mapping_count(void);
const TrainlogBodyZoneInitialMapping *trainlog_body_zone_initial_mapping_at(size_t index);
#endif

View file

@ -49,4 +49,19 @@ TrainlogStatus trainlog_catalog_create_exercise_profiled(
TrainlogExercise *output_exercise TrainlogExercise *output_exercise
); );
/* CONTRACT: creation and its zone relations commit as one user operation.
* A NULL primary is reserved for explicit unclassified/historic workflows;
* interactive strength creation supplies one assignable manifest zone. */
TrainlogStatus trainlog_catalog_create_exercise_profiled_with_zones(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count,
TrainlogExercise *output_exercise
);
#endif #endif

View file

@ -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 10 #define TRAINLOG_DATABASE_SCHEMA_VERSION 11
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;
@ -138,6 +138,77 @@ TrainlogStatus trainlog_database_list_exercises(
size_t *output_count size_t *output_count
); );
/**
* @brief Atomically replace one exercise's direct body-zone relations.
*
* @param primary_zone_id NULL or empty preserves the explicitly allowed
* unclassified state, which requires secondary_count == 0. Group zones
* cannot be assigned because their descendant relationship is derived
* from the canonical manifest.
* @param secondary_zone_ids Caller-owned array borrowed for this call only.
* @param secondary_count Bounded by the number of canonical manifest zones.
* @return INVALID_ARGUMENT for unknown/group/duplicate zones, NOT_FOUND for
* an unknown exercise, or the explicit persistence status.
*/
TrainlogStatus trainlog_database_replace_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
/* NOT_FOUND distinguishes an unknown exercise identity from the valid empty
* relation set. Output is caller-owned; output_count reports required capacity
* and no relation is truncated as a successful result. */
TrainlogStatus trainlog_database_list_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count
);
/* CONTRACT: the caller owns output storage; output_count reports the required
* count and INVALID_ARGUMENT is returned when capacity is insufficient.
* Filtering combines a normalized name prefix with either one
* direct zone, its manifest descendants, or the explicit unclassified state.
* `primary_only` excludes secondary participation without changing storage.
* zone_id and unclassified_only are mutually exclusive.
* The resulting exercise IDs compose with list_exercise_body_zones() and
* list_exercise_performance(); the latter exposes newest history and explicit
* MAX without duplicating either datum for a future session generator. */
TrainlogStatus trainlog_database_list_exercises_filtered(
TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
/* CONTRACT: name/profile/direct zones are one transaction and all string/list
* inputs are borrowed only for the duration of the call. A profile change is
* rejected once completed history references the exercise; a same-profile
* rename or zone replacement preserves exercise_id and history. Zone rules
* match replace_exercise_body_zones(). Unknown exercise IDs return NOT_FOUND;
* invalid metadata and name collisions remain explicit errors. */
TrainlogStatus trainlog_database_update_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
);
TrainlogStatus trainlog_database_insert_session( TrainlogStatus trainlog_database_insert_session(
TrainlogDatabase *database, TrainlogDatabase *database,
const TrainlogSessionInput *session const TrainlogSessionInput *session

View file

@ -14,6 +14,7 @@
#define TRAINLOG_NAME_MAX 200U #define TRAINLOG_NAME_MAX 200U
#define TRAINLOG_TIMESTAMP_MAX 40U #define TRAINLOG_TIMESTAMP_MAX 40U
#define TRAINLOG_NOTE_MAX 4000U #define TRAINLOG_NOTE_MAX 4000U
#define TRAINLOG_ZONE_ID_MAX 64U
typedef enum TrainlogTrackingMode { typedef enum TrainlogTrackingMode {
TRAINLOG_TRACKING_REPS = 0, TRAINLOG_TRACKING_REPS = 0,
@ -52,6 +53,19 @@ typedef struct TrainlogExercise {
TrainlogExerciseDataFields data_fields; TrainlogExerciseDataFields data_fields;
} TrainlogExercise; } TrainlogExercise;
typedef enum TrainlogBodyZoneRole {
TRAINLOG_BODY_ZONE_PRIMARY = 0,
TRAINLOG_BODY_ZONE_SECONDARY
} TrainlogBodyZoneRole;
/* CONTRACT: zone_id is a stable manifest identity. Database readers copy
* relations into caller-owned fixed-width records; display names never cross
* this persistence API. */
typedef struct TrainlogExerciseBodyZone {
char zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
TrainlogBodyZoneRole role;
} TrainlogExerciseBodyZone;
typedef struct TrainlogSetInput { typedef struct TrainlogSetInput {
int reps; int reps;
int duration_seconds; int duration_seconds;

View file

@ -23,6 +23,13 @@ equipment_catalog_generated = custom_target(
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_equipment_catalog.py', '@INPUT@', '@OUTPUT@'], command: [find_program('python3'), meson.project_source_root() / 'tools/generate_equipment_catalog.py', '@INPUT@', '@OUTPUT@'],
) )
body_zone_catalog_generated = custom_target(
'body_zone_catalog_generated',
input: meson.project_source_root() / 'catalog/body-zones-v1.json',
output: 'body_zone_catalog_generated.c',
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_body_zone_catalog.py', '@INPUT@', '@OUTPUT@'],
)
strict_c_args = [ strict_c_args = [
'-D_POSIX_C_SOURCE=200809L', '-D_POSIX_C_SOURCE=200809L',
'-Wconversion', '-Wconversion',
@ -46,6 +53,7 @@ trainlog_core_sources = files(
'src/sync_history.c', 'src/sync_history.c',
) )
trainlog_core_sources += equipment_catalog_generated trainlog_core_sources += equipment_catalog_generated
trainlog_core_sources += body_zone_catalog_generated
trainlog_core = static_library( trainlog_core = static_library(
'trainlog_core', 'trainlog_core',
@ -126,6 +134,14 @@ test_equipment_catalog = executable(
) )
test('equipment_catalog', test_equipment_catalog) test('equipment_catalog', test_equipment_catalog)
test_body_zones = executable(
'test_body_zones',
'tests/test_body_zones.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('body_zones', test_body_zones)
test_custom_equipment = executable( test_custom_equipment = executable(
'test_custom_equipment', 'test_custom_equipment',
'tests/test_custom_equipment.c', 'tests/test_custom_equipment.c',
@ -421,6 +437,18 @@ test(
args: [meson.project_source_root() / 'tests/test_exercise_reconciliation.py'], args: [meson.project_source_root() / 'tests/test_exercise_reconciliation.py'],
) )
test(
'body_zone_sync',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_body_zone_sync.py'],
)
test(
'body_zone_catalog_validation',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_body_zone_catalog.py'],
)
test_sync_direction = executable( test_sync_direction = executable(
'test_sync_direction', 'test_sync_direction',
'tests/test_sync_direction.c', 'tests/test_sync_direction.c',

View file

@ -212,6 +212,55 @@ TrainlogStatus trainlog_catalog_create_exercise_profiled(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
TrainlogStatus trainlog_catalog_create_exercise_profiled_with_zones(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count,
TrainlogExercise *output_exercise
)
{
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
char exercise_id[TRAINLOG_GENERATED_ID_CAPACITY];
TrainlogStatus status;
if (database == NULL || name == NULL || output_exercise == NULL ||
strlen(name) > TRAINLOG_NAME_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT;
status = trainlog_catalog_normalize_name(name, normalized, sizeof(normalized));
if (status != TRAINLOG_STATUS_OK) return status;
status = trainlog_id_generate("ex", exercise_id, sizeof(exercise_id));
if (status != TRAINLOG_STATUS_OK) return status;
status = trainlog_database_begin(database);
if (status != TRAINLOG_STATUS_OK) return status;
status = trainlog_database_insert_exercise_profiled(database, exercise_id,
name, normalized, tracking_mode, recording_mode, data_fields);
if (status == TRAINLOG_STATUS_OK) {
status = trainlog_database_replace_exercise_body_zones(database,
exercise_id, primary_zone_id, secondary_zone_ids, secondary_count);
}
if (status == TRAINLOG_STATUS_OK) {
status = trainlog_database_commit(database);
if (status != TRAINLOG_STATUS_OK) {
(void)trainlog_database_rollback(database);
}
} else {
(void)trainlog_database_rollback(database);
}
if (status != TRAINLOG_STATUS_OK) return status;
(void)memset(output_exercise, 0, sizeof(*output_exercise));
(void)snprintf(output_exercise->exercise_id,
sizeof(output_exercise->exercise_id), "%s", exercise_id);
(void)snprintf(output_exercise->name,
sizeof(output_exercise->name), "%s", name);
output_exercise->tracking_mode = tracking_mode;
output_exercise->recording_mode = recording_mode;
output_exercise->data_fields = data_fields;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_catalog_create_exercise( TrainlogStatus trainlog_catalog_create_exercise(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *name, const char *name,

View file

@ -4,6 +4,7 @@
*/ */
#include "trainlog/database.h" #include "trainlog/database.h"
#include "trainlog/body_zone_catalog.h"
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/equipment_catalog.h" #include "trainlog/equipment_catalog.h"
#include "trainlog/id.h" #include "trainlog/id.h"
@ -20,6 +21,12 @@ struct TrainlogDatabase {
sqlite3 *connection; sqlite3 *connection;
}; };
static TrainlogStatus lookup_exercise_row_id(
TrainlogDatabase *database,
const char *exercise_id,
sqlite3_int64 *output_row_id
);
static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id) static bool custom_equipment_exists(TrainlogDatabase *database, const char *equipment_id)
{ {
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
@ -321,6 +328,29 @@ static const char *const MIGRATE_V9_TO_V10_SQL =
"COMMIT;" "COMMIT;"
"PRAGMA foreign_keys = ON;"; "PRAGMA foreign_keys = ON;";
/*
* WHY: one text column cannot preserve a primary plus multiple secondary
* zones, and parent groups are derivable catalogue metadata.
* CONTRACT: v11 is additive and touches no exercise/session/history identity.
* INVARIANT: the primary key forbids duplicate roles for one zone and the
* partial unique index permits at most one primary relation per exercise.
*/
static const char *const CREATE_BODY_ZONE_RELATIONS_SQL =
"CREATE TABLE exercise_body_zones("
"exercise_row_id INTEGER NOT NULL "
"REFERENCES exercises(id) ON DELETE CASCADE,"
"zone_id TEXT NOT NULL,"
"role TEXT NOT NULL CHECK(role IN('primary','secondary')),"
"PRIMARY KEY(exercise_row_id,zone_id)"
");"
"CREATE UNIQUE INDEX exercise_body_zones_one_primary "
"ON exercise_body_zones(exercise_row_id) WHERE role='primary';"
"CREATE TABLE exercise_body_zone_sync("
"exercise_row_id INTEGER PRIMARY KEY "
"REFERENCES exercises(id) ON DELETE CASCADE,"
"synced_state TEXT NOT NULL"
");";
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 "
@ -619,6 +649,93 @@ static TrainlogStatus execute_sql(
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
static bool bind_initial_body_zone(
sqlite3_stmt *statement,
const char *exercise_id,
const char *zone_id,
const char *role
)
{
int rc;
if (sqlite3_reset(statement) != SQLITE_OK ||
sqlite3_clear_bindings(statement) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) {
return false;
}
rc = sqlite3_step(statement);
return rc == SQLITE_DONE;
}
static TrainlogStatus migrate_v10_to_v11(TrainlogDatabase *database)
{
static const char *const INSERT_SQL =
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"SELECT id,?2,?3 FROM exercises WHERE exercise_id=?1;";
sqlite3_stmt *statement = NULL;
size_t index;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK ||
execute_sql(database, CREATE_BODY_ZONE_RELATIONS_SQL) != TRAINLOG_STATUS_OK ||
sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK) {
goto rollback;
}
for (index = 0U; index < trainlog_body_zone_initial_mapping_count(); ++index) {
const TrainlogBodyZoneInitialMapping *mapping =
trainlog_body_zone_initial_mapping_at(index);
const char *cursor;
if (mapping == NULL ||
!bind_initial_body_zone(statement, mapping->exercise_id,
mapping->primary_zone_id, "primary")) {
goto rollback;
}
cursor = mapping->secondary_zone_ids;
while (cursor != NULL && cursor[0] != '\0') {
const char *end = strchr(cursor, '\n');
size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor);
char zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
if (length == 0U || length > TRAINLOG_ZONE_ID_MAX) goto rollback;
(void)memcpy(zone_id, cursor, length);
zone_id[length] = '\0';
if (!bind_initial_body_zone(statement, mapping->exercise_id,
zone_id, "secondary")) {
goto rollback;
}
cursor = end == NULL ? NULL : end + 1;
}
}
/* CONTRACT: synced_state is an internal comparison baseline, not domain
* data. `primary|secondary,...` is deterministic because zone IDs exclude
* delimiters and secondary IDs are sorted bytewise. */
if (execute_sql(database,
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) "
"SELECT e.id,COALESCE((SELECT p.zone_id FROM exercise_body_zones p "
"WHERE p.exercise_row_id=e.id AND p.role='primary'),'')||'|'||"
"COALESCE((SELECT group_concat(s.zone_id,',') FROM "
"(SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=e.id "
"AND role='secondary' ORDER BY zone_id) s),'') FROM exercises e;"
) != TRAINLOG_STATUS_OK) goto rollback;
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
goto rollback;
}
statement = NULL;
if (execute_sql(database, "PRAGMA user_version = 11;COMMIT;") != TRAINLOG_STATUS_OK) {
goto rollback;
}
return TRAINLOG_STATUS_OK;
rollback:
if (statement != NULL) (void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
return status;
}
static TrainlogStatus read_single_int_pragma( static TrainlogStatus read_single_int_pragma(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *sql, const char *sql,
@ -748,6 +865,8 @@ static TrainlogStatus initialize_or_validate_schema(
} }
} else if (version == 9) { } else if (version == 9) {
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL); status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
} else if (version == 10) {
status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
status = status =
@ -880,6 +999,10 @@ static TrainlogStatus initialize_or_validate_schema(
} }
} }
if (status == TRAINLOG_STATUS_OK) {
status = migrate_v10_to_v11(database);
}
if ( if (
status != status !=
TRAINLOG_STATUS_OK TRAINLOG_STATUS_OK
@ -887,7 +1010,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 v10" : "migrate database to schema v10", version == 0 ? "create schema v11" : "migrate database to schema v11",
database->connection, database->connection,
SQLITE_ERROR SQLITE_ERROR
); );
@ -1389,6 +1512,117 @@ TrainlogStatus trainlog_database_insert_exercise(
); );
} }
TrainlogStatus trainlog_database_update_exercise_profiled(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode,
TrainlogRecordingMode recording_mode,
TrainlogExerciseDataFields data_fields,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
)
{
sqlite3_stmt *statement = NULL;
sqlite3_int64 row_id;
TrainlogStatus status;
int rc;
bool profile_changed;
const char *tracking;
const char *recording;
if (database == NULL || exercise_id == NULL || name == NULL ||
normalized_name == NULL || name[0] == '\0' || normalized_name[0] == '\0' ||
strlen(name) > TRAINLOG_NAME_MAX ||
!exercise_profile_valid(tracking_mode, recording_mode, data_fields))
return TRAINLOG_STATUS_INVALID_ARGUMENT;
tracking = tracking_mode_to_sql(tracking_mode);
recording = recording_mode_to_sql(recording_mode);
status = lookup_exercise_row_id(database, exercise_id, &row_id);
if (status != TRAINLOG_STATUS_OK) return status;
if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
rc = sqlite3_prepare_v2(database->connection,
"SELECT tracking_mode,recording_mode,data_fields FROM exercises WHERE id=?1;",
-1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_ROW) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
profile_changed = strcmp((const char *)sqlite3_column_text(statement, 0), tracking) != 0 ||
strcmp((const char *)sqlite3_column_text(statement, 1), recording) != 0 ||
sqlite3_column_int64(statement, 2) != (sqlite3_int64)data_fields;
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
statement = NULL;
if (profile_changed) {
rc = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_exercises WHERE exercise_row_id=?1 LIMIT 1;",
-1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
rc = sqlite3_step(statement);
if (rc == SQLITE_ROW) {
status = TRAINLOG_STATUS_CONFLICT;
goto rollback;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
statement = NULL;
}
rc = sqlite3_prepare_v2(database->connection,
"UPDATE exercises SET name=?1,normalized_name=?2,tracking_mode=?3,"
"recording_mode=?4,data_fields=?5 WHERE id=?6;", -1, &statement, NULL);
if (rc != SQLITE_OK ||
sqlite3_bind_text(statement, 1, name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, tracking, -1, SQLITE_STATIC) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, recording, -1, SQLITE_STATIC) != SQLITE_OK ||
sqlite3_bind_int64(statement, 5, (sqlite3_int64)data_fields) != SQLITE_OK ||
sqlite3_bind_int64(statement, 6, row_id) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
rc = sqlite3_step(statement);
if (rc != SQLITE_DONE) {
status = rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT :
TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto rollback;
}
statement = NULL;
status = trainlog_database_replace_exercise_body_zones(database, exercise_id,
primary_zone_id, secondary_zone_ids, secondary_count);
if (status != TRAINLOG_STATUS_OK) goto rollback;
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK)
{
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
rollback:
if (statement != NULL) (void)sqlite3_finalize(statement);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
return status;
}
static TrainlogStatus count_query( static TrainlogStatus count_query(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *sql, const char *sql,
@ -1662,6 +1896,292 @@ static TrainlogStatus lookup_exercise_row_id(
: TRAINLOG_STATUS_DATABASE_ERROR; : TRAINLOG_STATUS_DATABASE_ERROR;
} }
static bool assignable_body_zone(const char *zone_id)
{
const TrainlogBodyZone *zone;
if (zone_id == NULL || zone_id[0] == '\0' ||
strlen(zone_id) > TRAINLOG_ZONE_ID_MAX) return false;
zone = trainlog_body_zone_catalog_lookup(zone_id);
return zone != NULL && !zone->is_group;
}
static TrainlogStatus insert_body_zone_relation(
TrainlogDatabase *database,
sqlite3_int64 exercise_row_id,
const char *zone_id,
const char *role
)
{
sqlite3_stmt *statement = NULL;
int rc = sqlite3_prepare_v2(database->connection,
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"VALUES(?1,?2,?3);", -1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_bind_int64(statement, 1, exercise_row_id) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, zone_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, role, -1, SQLITE_STATIC) != SQLITE_OK) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
rc = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK :
rc == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT :
TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_database_replace_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
const char *primary_zone_id,
const char *const *secondary_zone_ids,
size_t secondary_count
)
{
sqlite3_int64 row_id;
sqlite3_stmt *statement = NULL;
bool owns_transaction;
size_t index;
TrainlogStatus status;
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
exercise_id[0] == '\0' || secondary_count > trainlog_body_zone_catalog_count() ||
(secondary_count > 0U && secondary_zone_ids == NULL) ||
(secondary_count > 0U &&
(primary_zone_id == NULL || primary_zone_id[0] == '\0')) ||
(primary_zone_id != NULL && primary_zone_id[0] != '\0' &&
!assignable_body_zone(primary_zone_id))) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (index = 0U; index < secondary_count; ++index) {
size_t earlier;
if (!assignable_body_zone(secondary_zone_ids[index]) ||
(primary_zone_id != NULL && strcmp(primary_zone_id, secondary_zone_ids[index]) == 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
for (earlier = 0U; earlier < index; ++earlier) {
if (strcmp(secondary_zone_ids[earlier], secondary_zone_ids[index]) == 0)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
}
status = lookup_exercise_row_id(database, exercise_id, &row_id);
if (status != TRAINLOG_STATUS_OK) return status;
owns_transaction = sqlite3_get_autocommit(database->connection) != 0;
if (owns_transaction && execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_prepare_v2(database->connection,
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?1;",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
if (statement != NULL) (void)sqlite3_finalize(statement);
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
if (sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
statement = NULL;
if (primary_zone_id != NULL && primary_zone_id[0] != '\0') {
status = insert_body_zone_relation(database, row_id, primary_zone_id, "primary");
if (status != TRAINLOG_STATUS_OK) goto finish;
}
for (index = 0U; index < secondary_count; ++index) {
status = insert_body_zone_relation(database, row_id,
secondary_zone_ids[index], "secondary");
if (status != TRAINLOG_STATUS_OK) goto finish;
}
status = TRAINLOG_STATUS_OK;
finish:
if (owns_transaction) {
if (status == TRAINLOG_STATUS_OK) {
if (execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
}
} else {
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
}
}
return status;
}
TrainlogStatus trainlog_database_list_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
TrainlogExerciseBodyZone *output,
size_t capacity,
size_t *output_count
)
{
sqlite3_stmt *statement = NULL;
sqlite3_int64 row_id;
size_t count = 0U;
bool has_primary = false;
TrainlogStatus status;
int rc;
if (database == NULL || exercise_id == NULL || output_count == NULL ||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count = 0U;
status = lookup_exercise_row_id(database, exercise_id, &row_id);
if (status != TRAINLOG_STATUS_OK) return status;
rc = sqlite3_prepare_v2(database->connection,
"SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1 "
"ORDER BY CASE role WHEN 'primary' THEN 0 ELSE 1 END,zone_id;",
-1, &statement, NULL);
if (rc != SQLITE_OK || sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
if (statement != NULL) (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
const char *zone_id = (const char *)sqlite3_column_text(statement, 0);
const char *role = (const char *)sqlite3_column_text(statement, 1);
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(zone_id);
if (zone_id == NULL || role == NULL ||
zone == NULL || zone->is_group ||
(strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (count < capacity) {
(void)memset(&output[count], 0, sizeof(output[count]));
(void)snprintf(output[count].zone_id, sizeof(output[count].zone_id),
"%s", zone_id);
output[count].role = strcmp(role, "primary") == 0
? TRAINLOG_BODY_ZONE_PRIMARY : TRAINLOG_BODY_ZONE_SECONDARY;
}
if (strcmp(role, "primary") == 0) has_primary = true;
++count;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
/* INVARIANT: the explicitly unclassified state has no relations. A
* secondary-only raw SQLite state is corruption, not partial metadata. */
if (count > 0U && !has_primary) return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
}
static bool exercise_row_matches_body_zone(
TrainlogDatabase *database,
sqlite3_int64 row_id,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
bool *output_match
)
{
sqlite3_stmt *statement = NULL;
int rc;
bool has_relation = false;
bool has_primary = false;
bool match = false;
if (sqlite3_prepare_v2(database->connection,
"SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=?1;",
-1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_int64(statement, 1, row_id) != SQLITE_OK) {
if (statement != NULL) (void)sqlite3_finalize(statement);
return false;
}
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
const char *candidate = (const char *)sqlite3_column_text(statement, 0);
const char *role = (const char *)sqlite3_column_text(statement, 1);
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(candidate);
if (candidate == NULL || role == NULL ||
zone == NULL || zone->is_group ||
(strcmp(role, "primary") != 0 && strcmp(role, "secondary") != 0)) {
(void)sqlite3_finalize(statement);
return false;
}
has_relation = true;
if (strcmp(role, "primary") == 0) has_primary = true;
if (!unclassified_only &&
(!primary_only || strcmp(role, "primary") == 0) &&
(strcmp(candidate, zone_id) == 0 ||
(include_descendants &&
trainlog_body_zone_catalog_is_descendant(candidate, zone_id)))) {
match = true;
}
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK ||
(has_relation && !has_primary)) return false;
*output_match = unclassified_only ? !has_relation : match;
return true;
}
TrainlogStatus trainlog_database_list_exercises_filtered(
TrainlogDatabase *database,
const char *normalized_prefix,
const char *zone_id,
bool include_descendants,
bool primary_only,
bool unclassified_only,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
)
{
static const char *const SQL =
"SELECT id,exercise_id,name,tracking_mode,recording_mode,data_fields,normalized_name "
"FROM exercises ORDER BY name COLLATE NOCASE,exercise_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
size_t prefix_length;
int rc;
if (database == NULL || normalized_prefix == NULL || output_count == NULL ||
(capacity > 0U && output == NULL) ||
(unclassified_only && zone_id != NULL) ||
(!unclassified_only && zone_id != NULL &&
trainlog_body_zone_catalog_lookup(zone_id) == NULL))
return TRAINLOG_STATUS_INVALID_ARGUMENT;
prefix_length = strlen(normalized_prefix);
*output_count = 0U;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
sqlite3_int64 row_id = sqlite3_column_int64(statement, 0);
const char *normalized = (const char *)sqlite3_column_text(statement, 6);
bool matches = zone_id == NULL && !unclassified_only;
sqlite3_int64 data_fields = sqlite3_column_int64(statement, 5);
if (normalized == NULL || strncmp(normalized, normalized_prefix, prefix_length) != 0)
continue;
if (!matches && !exercise_row_matches_body_zone(database, row_id,
zone_id != NULL ? zone_id : "", include_descendants,
primary_only, unclassified_only, &matches)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (!matches) continue;
if (data_fields < 0 || (uint64_t)data_fields > UINT32_MAX) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (count < capacity) {
TrainlogExercise *item = &output[count];
(void)memset(item, 0, sizeof(*item));
(void)snprintf(item->exercise_id, sizeof(item->exercise_id), "%s",
(const char *)sqlite3_column_text(statement, 1));
(void)snprintf(item->name, sizeof(item->name), "%s",
(const char *)sqlite3_column_text(statement, 2));
item->tracking_mode = tracking_mode_from_sql(
(const char *)sqlite3_column_text(statement, 3));
item->recording_mode = recording_mode_from_sql(
(const char *)sqlite3_column_text(statement, 4));
item->data_fields = (TrainlogExerciseDataFields)data_fields;
}
++count;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
}
static TrainlogStatus lookup_session_row_id( static TrainlogStatus lookup_session_row_id(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *session_id, const char *session_id,

View file

@ -49,6 +49,9 @@ static const char *const MOBILE_EQUIPMENT_DEFINITIONS_NAME =
static const char *const PC_EQUIPMENT_DEFINITIONS_NAME = static const char *const PC_EQUIPMENT_DEFINITIONS_NAME =
"trainlog-pc-equipment-definitions-v1.json"; "trainlog-pc-equipment-definitions-v1.json";
static const char *const EXERCISE_BODY_ZONES_NAME =
"trainlog-exercise-body-zones-v1.json";
static const char *const SYNC_REQUEST_NAME = static const char *const SYNC_REQUEST_NAME =
"trainlog-sync-request-v1.json"; "trainlog-sync-request-v1.json";
@ -76,6 +79,12 @@ static const char *const PC_EQUIPMENT_DEFINITIONS_LOCAL =
static const char *const EQUIPMENT_DEFINITIONS_RESULT = static const char *const EQUIPMENT_DEFINITIONS_RESULT =
"/tmp/trainlog-equipment-definitions-result.txt"; "/tmp/trainlog-equipment-definitions-result.txt";
static const char *const EXERCISE_BODY_ZONES_LOCAL =
"/tmp/trainlog-exercise-body-zones-v1.json";
static const char *const EXERCISE_BODY_ZONES_RESULT =
"/tmp/trainlog-exercise-body-zones-result.txt";
static const char *const SYNC_REQUEST_LOCAL = static const char *const SYNC_REQUEST_LOCAL =
"/tmp/trainlog-sync-request-v1.json"; "/tmp/trainlog-sync-request-v1.json";
@ -2705,8 +2714,14 @@ TrainlogStatus trainlog_sync_run(
} }
if (require_request) { if (require_request) {
/* WHY: MediaStore cannot always reopen an older MTP-created object by
* canonical name, so Android may publish the next request as the exact
* scoped-storage collision form " (N).json". CONTRACT: select the
* newest canonical-or-suffixed request deterministically, exactly as
* for the other Android-originated artifacts; request_id replay
* protection below remains the trigger authority. */
status = status =
sync_receive_named( sync_receive_current_android_artifact(
&device, &device,
folder_id, folder_id,
SYNC_REQUEST_NAME, SYNC_REQUEST_NAME,
@ -2941,6 +2956,36 @@ TrainlogStatus trainlog_sync_run(
output output
); );
/* CONTRACT: body zones are one directional-neutral companion. Historic
* V2 publishers may omit it; when present it is applied only after the
* exercise definitions above established every ID or a source-V2-proven
* normalized-name alias. The helper consumes the retained mobile snapshot
* solely as reconciliation proof; it never infers identity from a name. */
status = mobile_export_is_v2
? sync_receive_current_android_artifact(&device, folder_id,
EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size)
: TRAINLOG_STATUS_NOT_FOUND;
if (status == TRAINLOG_STATUS_OK) {
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"Android→PC : zones corporelles : ",
useful[0] != '\0' ? useful : "import échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
} else if (status != TRAINLOG_STATUS_NOT_FOUND) {
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : lecture zones corporelles échouée.");
final_status = status;
goto finalize;
}
/* V1 exports carry no equipment signal. A V2 companion beside a historic /* V1 exports carry no equipment signal. A V2 companion beside a historic
* V1 snapshot belongs to another generation and must not be applied. Its * V1 snapshot belongs to another generation and must not be applied. Its
* absence therefore preserves existing associations rather than clearing * absence therefore preserves existing associations rather than clearing
@ -3071,6 +3116,42 @@ outbound:
goto finalize; goto finalize;
} }
status = sync_run_python_tool("export_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export zones corporelles échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EXERCISE_BODY_ZONES_LOCAL,
EXERCISE_BODY_ZONES_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication zones corporelles échouée.");
final_status = status;
goto finalize;
}
/* WHY: a new custom exercise has no common sync ancestor yet. Only after
* the exact companion is durably visible to the peer may the publisher
* acknowledge that snapshot as its baseline. Reusing the strict importer
* records equal state and can never union secondary zones. */
status = sync_run_python_tool("import_exercise_body_zones.py",
EXERCISE_BODY_ZONES_LOCAL, database_path, EXERCISE_BODY_ZONES_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
char useful[TRAINLOG_SYNC_ERROR_MAX + 1U];
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
sync_compose_diagnostic(output->error, sizeof(output->error),
"PC→Android : baseline zones corporelles : ",
useful[0] != '\0' ? useful : "enregistrement échoué");
final_status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finalize;
}
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL, status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
database_path, database_path,
PC_CATALOG_RESULT, tool_output, sizeof(tool_output)); PC_CATALOG_RESULT, tool_output, sizeof(tool_output));

View file

@ -26,6 +26,7 @@
#include "trainlog/bodyviz.h" #include "trainlog/bodyviz.h"
#include "trainlog/body_analytics.h" #include "trainlog/body_analytics.h"
#include "trainlog/body_zone_catalog.h"
#include "trainlog/catalog.h" #include "trainlog/catalog.h"
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/equipment_catalog.h" #include "trainlog/equipment_catalog.h"
@ -45,6 +46,7 @@
#define MAX_SETS_PER_EXERCISE 64U #define MAX_SETS_PER_EXERCISE 64U
#define MAX_SESSIONS 128U #define MAX_SESSIONS 128U
#define MAX_WEIGHT_POINTS 256U #define MAX_WEIGHT_POINTS 256U
#define MAX_BODY_ZONES 16U
#define MAX_BODY_METRIC_POINTS 256U #define MAX_BODY_METRIC_POINTS 256U
@ -2205,9 +2207,182 @@ static void section_scrollbar(
); );
} }
static bool secondary_zone_contains(
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
size_t count,
const char *zone_id,
size_t *output_index
)
{
size_t index;
for (index = 0U; index < count; ++index) {
if (strcmp(secondary[index], zone_id) == 0) {
if (output_index != NULL) *output_index = index;
return true;
}
}
return false;
}
/* WHY: terminal users select translated catalogue rows; accepting raw IDs
* would leak wire identity into product behavior and permit unknown values. */
static bool choose_body_zones(
char primary[TRAINLOG_ZONE_ID_MAX + 1U],
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
size_t *secondary_count,
bool require_primary
)
{
size_t selected = 0U;
size_t count = trainlog_body_zone_catalog_count();
if (primary == NULL || secondary == NULL || secondary_count == NULL ||
count == 0U || count > MAX_BODY_ZONES || *secondary_count > MAX_BODY_ZONES)
return false;
for (;;) {
size_t index;
int key;
draw_shell("Zones corporelles",
"↑↓ naviguer p principale Espace secondaire n non renseignée Entrée valider Échap annuler");
for (index = 0U; index < count; ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
bool is_primary = zone != NULL && strcmp(primary, zone->zone_id) == 0;
bool is_secondary = zone != NULL && secondary_zone_contains(
secondary, *secondary_count, zone->zone_id, NULL);
if (zone == NULL) continue;
if (index == selected)
trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE);
trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4,
" %s%-27s %s ", zone->parent_zone_id != NULL ? "" : "",
zone->display_name,
zone->is_group ? "[groupe]" : is_primary ? "[principale]" :
is_secondary ? "[secondaire]" : "[ ]");
if (index == selected)
trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE);
}
if (primary[0] == '\0')
trainlog_terminal_printf(tui_terminal, 4 + (int)count + 1, 4,
"Zone principale : Non renseignée");
trainlog_terminal_render(tui_terminal);
key = trainlog_terminal_get_key(tui_terminal);
if (key == 27) return false;
if (key == TRAINLOG_KEY_UP)
selected = selected > 0U ? selected - 1U : count - 1U;
else if (key == TRAINLOG_KEY_DOWN)
selected = selected + 1U < count ? selected + 1U : 0U;
else if (key == 'n' || key == 'N') {
primary[0] = '\0';
*secondary_count = 0U;
}
else if (key == 'p' || key == 'P') {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected);
size_t secondary_index;
if (zone != NULL && !zone->is_group) {
(void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id);
if (secondary_zone_contains(secondary, *secondary_count,
zone->zone_id, &secondary_index)) {
size_t move;
for (move = secondary_index; move + 1U < *secondary_count; ++move)
(void)memcpy(secondary[move], secondary[move + 1U],
sizeof(secondary[move]));
--*secondary_count;
}
}
} else if (key == ' ') {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected);
size_t secondary_index;
if (zone != NULL && primary[0] != '\0' && !zone->is_group &&
strcmp(primary, zone->zone_id) != 0) {
if (secondary_zone_contains(secondary, *secondary_count,
zone->zone_id, &secondary_index)) {
size_t move;
for (move = secondary_index; move + 1U < *secondary_count; ++move)
(void)memcpy(secondary[move], secondary[move + 1U],
sizeof(secondary[move]));
--*secondary_count;
} else if (*secondary_count < MAX_BODY_ZONES) {
(void)snprintf(secondary[*secondary_count],
TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id);
++*secondary_count;
}
}
} else if (key == '\n' || key == TRAINLOG_KEY_ENTER) {
if (require_primary && primary[0] == '\0') {
status_line("Une zone principale est requise.", TRAINLOG_COLOR_ERROR);
continue;
}
return true;
}
}
}
static bool load_exercise_body_zones(
TrainlogDatabase *database,
const char *exercise_id,
char primary[TRAINLOG_ZONE_ID_MAX + 1U],
char secondary[][TRAINLOG_ZONE_ID_MAX + 1U],
size_t *secondary_count
)
{
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
size_t count = 0U;
size_t index;
primary[0] = '\0';
*secondary_count = 0U;
if (trainlog_database_list_exercise_body_zones(database, exercise_id,
relations, MAX_BODY_ZONES, &count) != TRAINLOG_STATUS_OK) return false;
for (index = 0U; index < count; ++index) {
if (relations[index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
(void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s",
relations[index].zone_id);
} else if (*secondary_count < MAX_BODY_ZONES) {
(void)snprintf(secondary[*secondary_count], TRAINLOG_ZONE_ID_MAX + 1U,
"%s", relations[index].zone_id);
++*secondary_count;
}
}
return true;
}
static bool edit_exercise_body_zones(
TrainlogDatabase *database,
TrainlogExercise *exercise
)
{
char primary[TRAINLOG_ZONE_ID_MAX + 1U];
char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
const char *secondary_ids[MAX_BODY_ZONES];
size_t secondary_count = 0U;
size_t index;
char new_name[TRAINLOG_NAME_MAX + 1U];
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
const char *saved_name;
TrainlogStatus status;
if (!load_exercise_body_zones(database, exercise->exercise_id, primary,
secondary, &secondary_count)) return false;
if (!choose_body_zones(primary, secondary, &secondary_count, false)) return false;
draw_shell("Modifier l'exercice", "Nom vide = conserver · Échap annule");
if (!prompt_text(4, "Nouveau nom : ", new_name, sizeof(new_name), true)) return false;
saved_name = new_name[0] == '\0' ? exercise->name : new_name;
status = trainlog_catalog_normalize_name(saved_name, normalized, sizeof(normalized));
if (status != TRAINLOG_STATUS_OK) return false;
for (index = 0U; index < secondary_count; ++index) secondary_ids[index] = secondary[index];
status = trainlog_database_update_exercise_profiled(database,
exercise->exercise_id, saved_name, normalized, exercise->tracking_mode,
exercise->recording_mode, exercise->data_fields,
primary[0] == '\0' ? NULL : primary, secondary_ids, secondary_count);
if (status == TRAINLOG_STATUS_OK)
(void)snprintf(exercise->name, sizeof(exercise->name), "%s", saved_name);
status_line(status == TRAINLOG_STATUS_OK ? "✓ Exercice modifié." :
status == TRAINLOG_STATUS_CONFLICT ? "Conflit : nom ou profil déjà utilisé." :
"Impossible de modifier l'exercice.",
status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR);
wait_key();
return status == TRAINLOG_STATUS_OK;
}
static void screen_exercise_detail( static void screen_exercise_detail(
TrainlogDatabase *database, TrainlogDatabase *database,
const TrainlogExercise *exercise TrainlogExercise *exercise
) )
{ {
TrainlogResolvedEquipment explicit_items[64]; TrainlogResolvedEquipment explicit_items[64];
@ -2236,16 +2411,42 @@ static void screen_exercise_detail(
for (;;) { for (;;) {
size_t total = explicit_count + historic_count; size_t total = explicit_count + historic_count;
int row = 7; char primary[TRAINLOG_ZONE_ID_MAX + 1U];
char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
size_t secondary_count = 0U;
int row = 10;
int key; int key;
if (total > 0U && selected >= total) selected = total - 1U; if (total > 0U && selected >= total) selected = total - 1U;
draw_shell("TRAINLOG — Fiche exercice", draw_shell("TRAINLOG — Fiche exercice",
"↑↓ équipement Entrée fiche p performance m max mesuré b/Échap retour"); "↑↓ équipement Entrée fiche e modifier p performance m max mesuré b/Échap retour");
trainlog_terminal_printf(tui_terminal, 3, 4, "%s", exercise->name); trainlog_terminal_printf(tui_terminal, 3, 4, "%s", exercise->name);
trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s", trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s",
exercise->exercise_id, exercise->exercise_id,
exercise->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée"); exercise->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée");
trainlog_terminal_printf(tui_terminal, 6, 4, if (load_exercise_body_zones(database, exercise->exercise_id, primary,
secondary, &secondary_count) && primary[0] != '\0') {
const TrainlogBodyZone *primary_zone = trainlog_body_zone_catalog_lookup(primary);
const TrainlogBodyZone *group = primary_zone != NULL &&
primary_zone->parent_zone_id != NULL
? trainlog_body_zone_catalog_lookup(primary_zone->parent_zone_id) : NULL;
char secondary_names[256] = "";
for (index = 0U; index < secondary_count; ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(secondary[index]);
size_t used = strlen(secondary_names);
if (zone != NULL && used < sizeof(secondary_names) - 1U)
(void)snprintf(secondary_names + used, sizeof(secondary_names) - used,
"%s%s", used > 0U ? ", " : "", zone->display_name);
}
trainlog_terminal_printf(tui_terminal, 5, 4, "Zone principale : %s",
primary_zone != NULL ? primary_zone->display_name : primary);
trainlog_terminal_printf(tui_terminal, 6, 4, "Zones secondaires : %s",
secondary_names[0] != '\0' ? secondary_names : "Aucune");
trainlog_terminal_printf(tui_terminal, 7, 4, "Groupe : %s",
group != NULL ? group->display_name : "Aucun");
} else {
trainlog_terminal_printf(tui_terminal, 5, 4, "Zone : Non renseignée");
}
trainlog_terminal_printf(tui_terminal, 9, 4,
"Relations explicites du manifeste (%zu)", explicit_count); "Relations explicites du manifeste (%zu)", explicit_count);
if (explicit_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucune"); if (explicit_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucune");
for (index = 0U; index < explicit_count; ++index, ++row) { for (index = 0U; index < explicit_count; ++index, ++row) {
@ -2278,6 +2479,7 @@ static void screen_exercise_detail(
? &explicit_items[selected] : &historic_items[selected - explicit_count]); ? &explicit_items[selected] : &historic_items[selected - explicit_count]);
else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise); else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise);
else if (key == 'm' || key == 'M') screen_exercise_measured_max(database, exercise); else if (key == 'm' || key == 'M') screen_exercise_measured_max(database, exercise);
else if (key == 'e' || key == 'E') (void)edit_exercise_body_zones(database, exercise);
} }
} }
@ -2289,11 +2491,18 @@ static void screen_exercises(
size_t selected = 0U; size_t selected = 0U;
int nav_selected = 3; int nav_selected = 3;
int focus = 1; int focus = 1;
int zone_filter = -1; /* -1 all, catalogue index, count = unclassified. */
char search[TRAINLOG_NAME_MAX + 1U] = "";
for (;;) { for (;;) {
size_t count = 0U; size_t count = 0U;
size_t top = 0U; size_t top = 0U;
size_t index; size_t index;
size_t zone_count = trainlog_body_zone_catalog_count();
char normalized_search[(TRAINLOG_NAME_MAX * 4U) + 1U] = "";
const char *filter_zone_id = NULL;
const char *filter_label = "Toutes les zones";
bool unclassified_only = zone_filter == (int)zone_count;
bool large_layout = bool large_layout =
trainlog_terminal_columns(tui_terminal) >= 100 && trainlog_terminal_columns(tui_terminal) >= 100 &&
@ -2312,7 +2521,7 @@ static void screen_exercises(
trainlog_terminal_rows(tui_terminal) - 4; trainlog_terminal_rows(tui_terminal) - 4;
int first_row = int first_row =
list_top + 1; list_top + 2;
int visible_rows = int visible_rows =
framed framed
@ -2326,8 +2535,27 @@ static void screen_exercises(
return; return;
} }
if (trainlog_database_list_exercises( if (search[0] != '\0' && trainlog_catalog_normalize_name(search,
normalized_search, sizeof(normalized_search)) != TRAINLOG_STATUS_OK) {
normalized_search[0] = '\0';
}
if (zone_filter >= 0 && zone_filter < (int)zone_count) {
const TrainlogBodyZone *zone =
trainlog_body_zone_catalog_at((size_t)zone_filter);
if (zone != NULL) {
filter_zone_id = zone->zone_id;
filter_label = zone->display_name;
}
} else if (unclassified_only) {
filter_label = "Non renseignés";
}
if (trainlog_database_list_exercises_filtered(
database, database,
normalized_search,
filter_zone_id,
true,
false,
unclassified_only,
exercises, exercises,
MAX_EXERCISES, MAX_EXERCISES,
&count &count
@ -2375,7 +2603,7 @@ static void screen_exercises(
2, 2,
"%.*s", "%.*s",
trainlog_terminal_columns(tui_terminal) - 4, trainlog_terminal_columns(tui_terminal) - 4,
"Tab zone ↑↓/PgUp/PgDn catalogue ←→ menu Entrée ouvrir m max mesuré a ajouter 0/Home accueil F1-F5 direct b/Échap retour" "Tab zone ↑↓ catalogue / recherche z filtre x effacer Entrée fiche (e modifier) a ajouter b/Échap retour"
); );
trainlog_terminal_style_off(tui_terminal, trainlog_terminal_style_off(tui_terminal,
@ -2386,10 +2614,14 @@ static void screen_exercises(
} else { } else {
draw_shell( draw_shell(
"TRAINLOG — Exercices", "TRAINLOG — Exercices",
"↑↓ naviguer Entrée performance m max mesuré a ajouter b/Échap retour" "↑↓ naviguer / recherche z filtre x effacer Entrée fiche a ajouter b/Échap retour"
); );
} }
trainlog_terminal_printf(tui_terminal, first_row - 1, framed ? 5 : 4,
"Filtre : %s · Recherche : %s", filter_label,
search[0] != '\0' ? search : "");
if (framed) { if (framed) {
if (large_layout) { if (large_layout) {
focused_panel( focused_panel(
@ -2479,6 +2711,24 @@ static void screen_exercises(
trainlog_terminal_render(tui_terminal); trainlog_terminal_render(tui_terminal);
key = trainlog_terminal_get_key(tui_terminal); key = trainlog_terminal_get_key(tui_terminal);
if (key == '/') {
draw_shell("Recherche exercices", "Préfixe vide = tous · Échap annule");
if (prompt_text(4, "Préfixe : ", search, sizeof(search), true)) selected = 0U;
continue;
}
if (key == 'z' || key == 'Z') {
++zone_filter;
if (zone_filter > (int)zone_count) zone_filter = -1;
selected = 0U;
continue;
}
if (key == 'x' || key == 'X') {
search[0] = '\0';
zone_filter = -1;
selected = 0U;
continue;
}
if (large_layout && if (large_layout &&
(key == TRAINLOG_KEY_TAB || (key == TRAINLOG_KEY_TAB ||
key == TRAINLOG_KEY_SHIFT_TAB)) { key == TRAINLOG_KEY_SHIFT_TAB)) {
@ -2609,6 +2859,10 @@ if (primary_top_nav_activate(
TrainlogExerciseDataFields data_fields = 0U; TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created; TrainlogExercise created;
TrainlogStatus status; TrainlogStatus status;
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = "";
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
const char *secondary_ids[MAX_BODY_ZONES];
size_t secondary_count = 0U;
draw_shell( draw_shell(
"Nouvel exercice", "Nouvel exercice",
@ -2682,8 +2936,12 @@ if (primary_top_nav_activate(
TRAINLOG_EXERCISE_DATA_DISTANCE_KM; TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
} }
} }
status = if (!choose_body_zones(primary_zone, secondary_zones,
trainlog_catalog_create_exercise_profiled( &secondary_count, organization == 1)) continue;
for (index = 0U; index < secondary_count; ++index)
secondary_ids[index] = secondary_zones[index];
status =
trainlog_catalog_create_exercise_profiled_with_zones(
database, database,
name, name,
mode == 1 mode == 1
@ -2693,6 +2951,9 @@ status =
? TRAINLOG_RECORDING_CONTINUOUS ? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS, : TRAINLOG_RECORDING_SETS,
data_fields, data_fields,
primary_zone[0] == '\0' ? NULL : primary_zone,
secondary_ids,
secondary_count,
&created &created
); );
@ -2734,6 +2995,11 @@ static bool create_exercise_inline(
TrainlogExerciseDataFields data_fields = 0U; TrainlogExerciseDataFields data_fields = 0U;
TrainlogExercise created; TrainlogExercise created;
TrainlogStatus status; TrainlogStatus status;
char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = "";
char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U];
const char *secondary_ids[MAX_BODY_ZONES];
size_t secondary_count = 0U;
size_t index;
draw_shell( draw_shell(
"Nouvel exercice", "Nouvel exercice",
@ -2807,8 +3073,12 @@ static bool create_exercise_inline(
TRAINLOG_EXERCISE_DATA_DISTANCE_KM; TRAINLOG_EXERCISE_DATA_DISTANCE_KM;
} }
} }
status = if (!choose_body_zones(primary_zone, secondary_zones,
trainlog_catalog_create_exercise_profiled( &secondary_count, organization == 1)) return false;
for (index = 0U; index < secondary_count; ++index)
secondary_ids[index] = secondary_zones[index];
status =
trainlog_catalog_create_exercise_profiled_with_zones(
database, database,
name, name,
mode == 1 mode == 1
@ -2818,6 +3088,9 @@ status =
? TRAINLOG_RECORDING_CONTINUOUS ? TRAINLOG_RECORDING_CONTINUOUS
: TRAINLOG_RECORDING_SETS, : TRAINLOG_RECORDING_SETS,
data_fields, data_fields,
primary_zone[0] == '\0' ? NULL : primary_zone,
secondary_ids,
secondary_count,
&created &created
); );

View file

@ -98,7 +98,9 @@ static bool test_body_metrics(void)
int main(void) int main(void)
{ {
CHECK(test_body_metrics()); if (!test_body_metrics()) {
return 1;
}
(void)printf("PASS body_metrics\n"); (void)printf("PASS body_metrics\n");
return 0; return 0;
} }

230
tui/tests/test_body_zones.c Normal file
View file

@ -0,0 +1,230 @@
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "trainlog/body_zone_catalog.h"
#include "trainlog/catalog.h"
#include "trainlog/database.h"
#define CHECK(condition) do { if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return 1; } } while (0)
static int taxonomy_contract(void)
{
size_t index;
CHECK(trainlog_body_zone_catalog_count() == 11U);
for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
const TrainlogBodyZone *ancestors[11];
size_t other;
CHECK(zone != NULL);
CHECK(trainlog_body_zone_catalog_lookup(zone->zone_id) == zone);
CHECK(trainlog_body_zone_catalog_ancestors(zone->zone_id, ancestors, 11U) <= 2U);
if (zone->parent_zone_id != NULL)
CHECK(trainlog_body_zone_catalog_lookup(zone->parent_zone_id) != NULL);
for (other = index + 1U; other < trainlog_body_zone_catalog_count(); ++other) {
const TrainlogBodyZone *candidate = trainlog_body_zone_catalog_at(other);
CHECK(candidate != NULL);
CHECK(strcmp(zone->zone_id, candidate->zone_id) != 0);
CHECK(zone->sort_order != candidate->sort_order);
}
}
CHECK(trainlog_body_zone_catalog_lookup("full_body")->parent_zone_id == NULL);
CHECK(trainlog_body_zone_catalog_lookup("upper_body")->is_group);
CHECK(trainlog_body_zone_catalog_lookup("lower_body")->is_group);
CHECK(trainlog_body_zone_catalog_is_descendant("chest", "upper_body"));
CHECK(!trainlog_body_zone_catalog_is_descendant("core", "upper_body"));
return 0;
}
static int relation_and_filter_contract(void)
{
char path[] = "/tmp/trainlog-body-zones-XXXXXX";
int fd = mkstemp(path);
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
TrainlogExercise created;
TrainlogExercise lower;
TrainlogExercise results[8];
TrainlogExerciseBodyZone relations[4];
const char *secondary[] = {"shoulders", "arms"};
const char *duplicate[] = {"arms", "arms"};
const char *same_as_primary[] = {"chest"};
const char *orphan_secondary[] = {"arms"};
size_t count = 0U;
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database,
"Chest custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U,
"chest", secondary, 2U, &created) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id,
relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 3U);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_missing",
relations, 4U, &count) == TRAINLOG_STATUS_NOT_FOUND);
CHECK(relations[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
CHECK(strcmp(relations[0].zone_id, "chest") == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"SELECT id,'back','primary' FROM exercises WHERE exercise_id LIKE 'ex_%';",
NULL, NULL, NULL) == SQLITE_CONSTRAINT);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
"chest", same_as_primary, 1U) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
"chest", duplicate, 2U) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
NULL, orphan_secondary, 1U) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
"upper_body", NULL, 0U) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
"unknown", NULL, 0U) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_list_exercises_filtered(database, "chest", "upper_body",
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && strcmp(results[0].exercise_id, created.exercise_id) == 0);
CHECK(trainlog_database_list_exercises_filtered(database, "", "chest",
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
CHECK(trainlog_database_list_exercises_filtered(database, "chest", "back",
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "back",
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database,
"Glute custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U,
"glutes", NULL, 0U, &lower) == TRAINLOG_STATUS_OK);
CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database,
"Thigh custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U,
"thighs", NULL, 0U, &lower) == TRAINLOG_STATUS_OK);
CHECK(trainlog_catalog_create_exercise_profiled_with_zones(database,
"Calf custom", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U,
"calves", NULL, 0U, &lower) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercises_filtered(database, "", "lower_body",
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U);
CHECK(trainlog_database_update_exercise_profiled(database, created.exercise_id,
"Chest custom renamed", "chest custom renamed", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, 0U, "back", NULL, 0U) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id,
relations, 4U, &count) == TRAINLOG_STATUS_OK && count == 1U &&
strcmp(relations[0].zone_id, "back") == 0);
CHECK(trainlog_database_replace_exercise_body_zones(database, created.exercise_id,
NULL, NULL, 0U) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
/* Inject against the real row with a prepared statement so the corruption
* fixture never embeds its generated UUID. */
{
sqlite3_stmt *corrupt = NULL;
CHECK(sqlite3_prepare_v2(raw,
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"SELECT id,'arms','secondary' FROM exercises WHERE exercise_id=?1;",
-1, &corrupt, NULL) == SQLITE_OK);
CHECK(sqlite3_bind_text(corrupt, 1, created.exercise_id, -1,
SQLITE_TRANSIENT) == SQLITE_OK);
CHECK(sqlite3_step(corrupt) == SQLITE_DONE);
CHECK(sqlite3_finalize(corrupt) == SQLITE_OK);
}
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id,
relations, 4U, &count) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(trainlog_database_list_exercises_filtered(database, "", "chest",
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "DELETE FROM exercise_body_zones WHERE role='secondary';",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) "
"SELECT id,'upper_body','primary' FROM exercises "
"WHERE normalized_name='chest custom renamed';",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id,
relations, 4U, &count) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "DELETE FROM exercise_body_zones WHERE zone_id='upper_body';",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_list_exercises_filtered(database, "", NULL,
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
trainlog_database_close(database);
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercise_body_zones(database, created.exercise_id,
relations, 4U, &count) == TRAINLOG_STATUS_OK && count == 0U);
trainlog_database_close(database);
CHECK(unlink(path) == 0);
return 0;
}
static int migration_preserves_identity_and_history(void)
{
char path[] = "/tmp/trainlog-body-zones-v10-XXXXXX";
int fd = mkstemp(path);
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone relations[4];
size_t count = 0U;
int version = 0;
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"PRAGMA foreign_keys=ON;"
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,"
"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);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE);"
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
"'Leg press','leg press','reps','sets',0);"
"INSERT INTO sessions VALUES(3,'se_preserved');"
"PRAGMA user_version=10;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 11);
CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);
trainlog_database_close(database);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
{
sqlite3_stmt *statement = NULL;
CHECK(sqlite3_prepare_v2(raw,
"SELECT (SELECT COUNT(*) FROM exercises WHERE id=7 AND "
"exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde'),"
"(SELECT COUNT(*) FROM sessions WHERE id=3 AND session_id='se_preserved');",
-1, &statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_column_int(statement, 1) == 1);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
CHECK(sqlite3_prepare_v2(raw, "PRAGMA foreign_key_check;", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_DONE);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
}
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(unlink(path) == 0);
return 0;
}
int main(void)
{
CHECK(taxonomy_contract() == 0);
CHECK(relation_and_filter_contract() == 0);
CHECK(migration_preserves_identity_and_history() == 0);
return 0;
}

View file

@ -51,7 +51,7 @@ static bool test_custom_equipment_round_trip(void)
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); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 10); CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
(void)memset(&custom, 0, sizeof(custom)); (void)memset(&custom, 0, sizeof(custom));
(void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id),
"%s", "eq_123e4567-e89b-42d3-a456-426614174000"); "%s", "eq_123e4567-e89b-42d3-a456-426614174000");
@ -116,7 +116,7 @@ static bool test_custom_equipment_round_trip(void)
int main(void) int main(void)
{ {
CHECK(test_custom_equipment_round_trip()); if (!test_custom_equipment_round_trip()) return 1;
(void)printf("PASS custom_equipment\n"); (void)printf("PASS custom_equipment\n");
return 0; return 0;
} }

View file

@ -141,6 +141,11 @@ static bool test_explicit_max_round_trip_and_identity(void)
static bool test_v8_migration_refuses_to_guess_multiple_attempts(void) static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
{ {
static const char *const SQL = static const char *const SQL =
/* A real v8 database always owns the exercise table even though this
* MAX-only fixture has no exercise rows. */
"CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,"
"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);"
"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,recording_mode TEXT);"

View file

@ -187,7 +187,7 @@ static bool test_v4_to_current_preserves_session(void)
TRAINLOG_DATABASE_SCHEMA_VERSION TRAINLOG_DATABASE_SCHEMA_VERSION
); );
CHECK(version == 10); CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK( CHECK(
trainlog_database_get_session_details( trainlog_database_get_session_details(
@ -226,9 +226,7 @@ static bool test_v4_to_current_preserves_session(void)
int main(void) int main(void)
{ {
CHECK( if (!test_v4_to_current_preserves_session()) return 1;
test_v4_to_current_preserves_session()
);
(void)printf( (void)printf(
"PASS schema_v5_migration\n" "PASS schema_v5_migration\n"

View file

@ -1,6 +1,6 @@
/** /**
* @file test_schema_v7_migration.c * @file test_schema_v7_migration.c
* @brief Synthetic v7 -> v8 migration and reopen regression coverage. * @brief Synthetic v7 -> current migration and reopen regression coverage.
*/ */
#include <stdbool.h> #include <stdbool.h>
@ -88,7 +88,7 @@ static bool verify_preserved_values(const char *path)
return true; return true;
} }
static bool test_v7_migrates_and_v8_reopens(void) static bool test_v7_migrates_and_current_reopens(void)
{ {
char path[] = "/tmp/trainlog-schema-v7-XXXXXX"; char path[] = "/tmp/trainlog-schema-v7-XXXXXX";
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
@ -103,7 +103,7 @@ static bool test_v7_migrates_and_v8_reopens(void)
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
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); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 10); CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(database); trainlog_database_close(database);
CHECK(verify_preserved_values(path)); CHECK(verify_preserved_values(path));
@ -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 v10: SQLite rc=", CHECK(strncmp(diagnostic, "migrate database to schema v11: SQLite rc=",
strlen("migrate database to schema v10: SQLite rc=")) == 0); strlen("migrate database to schema v11: 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-v11-XXXXXX"; char path[] = "/tmp/trainlog-schema-v12-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=11;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=12;", 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 11 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 12 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;
@ -189,10 +189,10 @@ static bool test_unrecognized_historic_schema_has_application_diagnostic(void)
int main(void) int main(void)
{ {
CHECK(test_v7_migrates_and_v8_reopens()); if (!test_v7_migrates_and_current_reopens() ||
CHECK(test_v7_migration_sqlite_failure_has_diagnostic()); !test_v7_migration_sqlite_failure_has_diagnostic() ||
CHECK(test_newer_schema_has_application_diagnostic()); !test_newer_schema_has_application_diagnostic() ||
CHECK(test_unrecognized_historic_schema_has_application_diagnostic()); !test_unrecognized_historic_schema_has_application_diagnostic()) return 1;
(void)printf("PASS schema_v7_migration\n"); (void)printf("PASS schema_v7_migration\n");
return 0; return 0;
} }

View file

@ -64,9 +64,9 @@ static bool scalar_text_is(sqlite3 *db, const char *sql, const char *expected)
return matches; return matches;
} }
static bool test_v9_to_v10_is_lossless(void) static bool test_v9_to_current_is_lossless(void)
{ {
char path[] = "/tmp/trainlog-schema-v9-v10-XXXXXX"; char path[] = "/tmp/trainlog-schema-v9-current-XXXXXX";
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
sqlite3_stmt *rows = NULL; sqlite3_stmt *rows = NULL;
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
@ -83,7 +83,7 @@ static bool test_v9_to_v10_is_lossless(void)
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); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 10); CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
CHECK(foreign_keys == 1); CHECK(foreign_keys == 1);
trainlog_database_close(database); trainlog_database_close(database);
@ -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 v10") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v11") != 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);
@ -176,8 +176,8 @@ static bool test_v9_to_v10_failure_rolls_back(void)
int main(void) int main(void)
{ {
CHECK(test_v9_to_v10_is_lossless()); if (!test_v9_to_current_is_lossless() ||
CHECK(test_v9_to_v10_failure_rolls_back()); !test_v9_to_v10_failure_rolls_back()) return 1;
(void)printf("PASS schema_v9_migration\n"); (void)printf("PASS schema_v9_migration\n");
return 0; return 0;
} }

View file

@ -79,6 +79,21 @@ int main(void)
&selected)); &selected));
CHECK(selected == 1U); CHECK(selected == 1U);
(void)snprintf(entries[0].name, sizeof(entries[0].name), "%s",
"trainlog-sync-request-v1.json");
entries[0].item_id = 60U;
entries[0].modification_unix_seconds = 10U;
(void)snprintf(entries[1].name, sizeof(entries[1].name), "%s",
"trainlog-sync-request-v1 (2).json");
entries[1].item_id = 61U;
entries[1].modification_unix_seconds = 40U;
CHECK(trainlog_sync_select_android_artifact(
entries,
2U,
"trainlog-sync-request-v1.json",
&selected));
CHECK(selected == 1U);
report.success = true; report.success = true;
report.direction = TRAINLOG_SYNC_BIDIRECTIONAL; report.direction = TRAINLOG_SYNC_BIDIRECTIONAL;
report.exercises_reconciled = 1U; report.exercises_reconciled = 1U;

View file

@ -219,30 +219,33 @@ static bool test_empty_sets_cannot_finish(void)
{ {
TrainlogSessionDraftExercise draft; TrainlogSessionDraftExercise draft;
TrainlogTerminal terminal; TrainlogTerminal terminal;
TrainlogDatabase *database = NULL;
size_t count = 1U; size_t count = 1U;
const int events[] = {'f', 'x', 'q'}; const int events[] = {'f', 'x', 'q'};
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
(void)memset(&draft, 0, sizeof(draft)); (void)memset(&draft, 0, sizeof(draft));
draft.input.recording_mode = TRAINLOG_RECORDING_SETS; draft.input.recording_mode = TRAINLOG_RECORDING_SETS;
draft.tracking_mode = TRAINLOG_TRACKING_REPS; draft.tracking_mode = TRAINLOG_TRACKING_REPS;
(void)snprintf(draft.name, sizeof(draft.name), "Squat"); (void)snprintf(draft.name, sizeof(draft.name), "Squat");
script(&terminal, events, sizeof(events) / sizeof(events[0])); script(&terminal, events, sizeof(events) / sizeof(events[0]));
tui_terminal = &terminal; tui_terminal = &terminal;
CHECK(!edit_session_draft(NULL, &draft, &count, CHECK(!edit_session_draft(database, &draft, &count,
TRAINLOG_SESSION_TRAINING)); TRAINLOG_SESSION_TRAINING));
CHECK(count == 1U && draft.input.set_count == 0U); CHECK(count == 1U && draft.input.set_count == 0U);
CHECK(strstr(terminal.output, CHECK(strstr(terminal.output,
"Ajoutez au moins une série réalisée pour Squat.") != NULL); "Ajoutez au moins une série réalisée pour Squat.") != NULL);
tui_terminal = NULL; tui_terminal = NULL;
trainlog_database_close(database);
return true; return true;
} }
int main(void) int main(void)
{ {
CHECK(test_assistance_creation_labels()); if (!test_assistance_creation_labels() ||
CHECK(test_duration_creation_starts_empty()); !test_duration_creation_starts_empty() ||
CHECK(test_append_requires_actual_and_rolls_back()); !test_append_requires_actual_and_rolls_back() ||
CHECK(test_empty_sets_cannot_finish()); !test_empty_sets_cannot_finish()) return 1;
(void)printf("PASS tui_workflows\n"); (void)printf("PASS tui_workflows\n");
return 0; return 0;
} }