feat(exercises): add hierarchical body zones
This commit is contained in:
parent
1b08db009e
commit
1aad7b48b3
54 changed files with 4264 additions and 213 deletions
68
CHANGELOG.md
68
CHANGELOG.md
|
|
@ -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:
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 -> CatalogInboxResult.Imported(
|
null -> when (val bodyZones = importPcBodyZones(directory)) {
|
||||||
imported = result.imported,
|
null -> CatalogInboxResult.Imported(
|
||||||
reconciled = result.reconciled,
|
imported = result.imported,
|
||||||
skipped = result.skipped,
|
reconciled = result.reconciled,
|
||||||
)
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
39
catalog/body-zones-v1.json
Normal file
39
catalog/body-zones-v1.json
Normal 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."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
24
docs/tui.md
24
docs/tui.md
|
|
@ -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
|
||||||
|
|
|
||||||
88
tests/test_body_zone_catalog.py
Normal file
88
tests/test_body_zone_catalog.py
Normal 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()
|
||||||
215
tests/test_body_zone_sync.py
Normal file
215
tests/test_body_zone_sync.py
Normal 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()
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
88
tools/export_exercise_body_zones.py
Normal file
88
tools/export_exercise_body_zones.py
Normal 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)
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
162
tools/generate_body_zone_catalog.py
Normal file
162
tools/generate_body_zone_catalog.py
Normal 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;
|
||||||
|
}
|
||||||
|
''')
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
245
tools/import_exercise_body_zones.py
Normal file
245
tools/import_exercise_body_zones.py
Normal 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)
|
||||||
|
|
@ -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"]),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
50
tui/include/trainlog/body_zone_catalog.h
Normal file
50
tui/include/trainlog/body_zone_catalog.h
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
297
tui/src/tui.c
297
tui/src/tui.c
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
230
tui/tests/test_body_zones.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue