fix(tui): use shared header on history screen

This commit is contained in:
fy59 2026-09-07 23:05:10 +02:00
parent c9c4d3b9ce
commit 7093dd5606
41 changed files with 2689 additions and 321 deletions

View file

@ -9,6 +9,13 @@ Detailed implementation chronology remains available in Git history and
### Added ### Added
- multi-occurrence session V2: stable per-occurrence `entry_id`, repeated
catalogue exercises in one session, per-set actual weights, and occurrence
equipment associations across Android, desktop, import and export;
- shared versioned equipment catalogue, Android machine selection/search,
Android-local custom equipment creation, and explicit rejection of unknown
equipment identities rather than silent association loss;
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename, - Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
explicit invalid/conflict/profile/database results, and profile locking once explicit invalid/conflict/profile/database results, and profile locking once
completed history or an active draft references the exercise; completed history or an active draft references the exercise;
@ -45,6 +52,14 @@ Detailed implementation chronology remains available in Git history and
### Changed ### Changed
- desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows
while adding durable occurrence identities and occurrence-level equipment;
- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts
remain readable as historical formats and are not redefined for repeated
occurrences;
- synchronization invokes each local helper with the explicit XDG-resolved
desktop database path and records the concrete equipment-import failure;
- same-ID Android ↔ PC catalog reconciliation now updates display-name metadata - same-ID Android ↔ PC catalog reconciliation now updates display-name metadata
in place and rejects a different-ID normalized-name collision, preserving in place and rejects a different-ID normalized-name collision, preserving
synchronization identity and preventing renamed duplicates; synchronization identity and preventing renamed duplicates;
@ -68,6 +83,12 @@ Detailed implementation chronology remains available in Git history and
### Fixed ### Fixed
- equipment companion import previously omitted its required `--database`
argument and blocked synchronization after a successful session import;
- PC catalogue/mobile export paths now accept schema v7 and preserve catalogue
tracking metadata; Android completed-session equipment editing now targets
the stable occurrence `entry_id`, not an ambiguous catalogue exercise ID;
- in-progress Android workout loss when leaving the foreground or recreating - in-progress Android workout loss when leaving the foreground or recreating
the Activity/process; the Activity/process;
- missing selected-exercise recovery preserves raw partial input and reports a - missing selected-exercise recovery preserves raw partial input and reports a
@ -88,12 +109,12 @@ Current validated baseline:
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V7=PASS
DESKTOP_TESTS=22/22 PASS DESKTOP_TESTS=25/25 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V4=PASS ANDROID_LOCAL_DATABASE_V7=PASS
ANDROID_HOST_TESTS=8/8 PASS ANDROID_HOST_TESTS=8/8 PASS
ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS
ANDROID_SESSION_DRAFT_V1=PASS ANDROID_SESSION_DRAFT_V1=PASS
@ -109,6 +130,8 @@ ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
``` ```
### Measured max v1 ### Measured max v1

View file

@ -16,9 +16,9 @@ desktop.
```text ```text
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V7=PASS
ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_LOCAL_DATABASE_V4=PASS ANDROID_LOCAL_DATABASE_V7=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
@ -32,8 +32,10 @@ TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
DESKTOP_TESTS=22/22 PASS DESKTOP_TESTS=25/25 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
``` ```
@ -100,6 +102,13 @@ Actual repetition sets are stored independently. Compact input supports:
4..10..4 4..10..4
``` ```
A session may contain several ordered occurrences of the same catalogue
exercise. Each occurrence has a stable `entry_id`, distinct from the stable
`exercise_id` of the catalogue item. Equipment selection belongs to that
occurrence, as do its actual per-set loads. `external` records an applied or
machine-displayed load; `assistance` records assistance and is not interpreted
as increasing strength.
## Repository layout ## Repository layout
```text ```text
@ -138,6 +147,10 @@ export or desktop synchronization as completed sessions.
Schema v4 migrates additively from v3, preserving existing capture data. See Schema v4 migrates additively from v3, preserving 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 v7. Its additive v4 -> v7 chain adds the shared
equipment catalogue, per-occurrence equipment links and durable occurrence
identities without recreating completed history or 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
continue to resolve the same logical exercise. Referenced profiles are locked; continue to resolve the same logical exercise. Referenced profiles are locked;
@ -159,6 +172,16 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
## Android-triggered synchronization ## Android-triggered synchronization
Launch the desktop TUI from a built checkout with:
```bash
trainlog
```
The usual user command resolves to `build/tui/trainlog` in this checkout. The
desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
Build the desktop first, then install the user service: Build the desktop first, then install the user service:
```bash ```bash
@ -181,6 +204,9 @@ Sync
The request is consumed by `trainlog-syncd`, the shared bidirectional engine The request is consumed by `trainlog-syncd`, the shared bidirectional engine
runs, a receipt is returned to Android, and the PC catalog is applied locally. runs, a receipt is returned to Android, and the PC catalog is applied locally.
The active completed-session exchange is V2 and preserves occurrence
`entry_id`, per-set weights and equipment associations. Frozen V1 artifacts
remain readable as legacy artifacts; they are not silently redefined as V2.
## Documentation ## Documentation
@ -246,5 +272,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=22/22 PASS DESKTOP_TESTS=25/25 PASS
``` ```

View file

@ -27,6 +27,14 @@ android {
compose = true compose = true
} }
sourceSets {
getByName("main") {
/* CONTRACT: this repository-level manifest is the one canonical
* equipment source. Android must not fork it into Kotlin constants. */
assets.srcDir("../../catalog")
}
}
testOptions { testOptions {
unitTests.isIncludeAndroidResources = true unitTests.isIncludeAndroidResources = true
unitTests.all { unitTests.all {

View file

@ -0,0 +1,116 @@
package com.labfytools.trainlog.data
import android.content.Context
import org.json.JSONObject
import java.text.Normalizer
import java.util.Locale
enum class EquipmentLoadSemantics {
EXTERNAL,
ASSISTANCE,
BODYWEIGHT,
CARDIO,
}
data class EquipmentCatalogEntry(
val equipmentId: String,
val labelName: String,
val displayName: String,
val aliases: List<String>,
val type: String,
val loadSemantics: EquipmentLoadSemantics,
)
data class ExerciseEquipmentCatalogRelation(
val exerciseId: String,
val equipmentId: String,
val loadSemantics: EquipmentLoadSemantics,
)
/**
* CONTRACT: `catalog/equipment-v1.json` is shared repository data. This
* reader deliberately has no fallback list: a missing or malformed manifest
* is an explicit deployment error, never a silently divergent Android list.
*/
object EquipmentCatalog {
private const val ASSET_NAME = "equipment-v1.json"
fun load(context: Context): List<EquipmentCatalogEntry> {
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
JSONObject(it.readText())
}
check(root.getString("format") == "trainlog-equipment-catalog")
check(root.getInt("version") == 1)
val seen = mutableSetOf<String>()
return buildList {
val entries = root.getJSONArray("equipment")
for (index in 0 until entries.length()) {
val item = entries.getJSONObject(index)
val id = item.getString("id").trim()
check(id.isNotEmpty()) { "equipment_id vide" }
check(seen.add(id)) { "equipment_id dupliqué: $id" }
val aliases = item.getJSONArray("aliases")
val parsedAliases = List(aliases.length()) { aliases.getString(it).trim() }
check(parsedAliases.all { it.isNotEmpty() }) { "alias vide pour $id" }
val labelName = item.getString("label_name").trim()
val displayName = item.getString("display_name").trim()
check(displayName.isNotEmpty()) { "display_name vide pour $id" }
val type = item.getString("type").trim()
check(type.isNotEmpty()) { "type vide pour $id" }
add(
EquipmentCatalogEntry(
equipmentId = id,
labelName = labelName,
displayName = displayName,
aliases = parsedAliases,
type = type,
loadSemantics = EquipmentLoadSemantics.valueOf(
item.getString("load_semantics").uppercase(Locale.ROOT),
),
),
)
}
}
}
fun exerciseEquipmentRelations(
context: Context,
): List<ExerciseEquipmentCatalogRelation> {
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
JSONObject(it.readText())
}
val knownEquipment = load(context).map { it.equipmentId }.toSet()
val relations = root.getJSONArray("exercise_equipment")
return List(relations.length()) { index ->
val item = relations.getJSONObject(index)
val equipmentId = item.getString("equipment_id")
check(equipmentId in knownEquipment)
ExerciseEquipmentCatalogRelation(
exerciseId = item.getString("exercise_id"),
equipmentId = equipmentId,
loadSemantics = EquipmentLoadSemantics.valueOf(
item.getString("load_semantics").uppercase(Locale.ROOT),
),
)
}
}
fun search(
entries: List<EquipmentCatalogEntry>,
query: String,
): List<EquipmentCatalogEntry> {
val needle = normalize(query)
if (needle.isEmpty()) return entries
return entries.filter { entry ->
sequenceOf(entry.displayName, entry.labelName)
.plus(entry.aliases.asSequence())
.any { normalize(it).contains(needle) }
}
}
private fun normalize(value: String): String =
Normalizer.normalize(value, Normalizer.Form.NFD)
.replace("\\p{M}+".toRegex(), "")
.lowercase(Locale.ROOT)
.trim()
}

View file

@ -137,14 +137,17 @@ class SyncCatalogInbox(
) )
) { ) {
is PcCatalogImportResult.Applied -> is PcCatalogImportResult.Applied ->
CatalogInboxResult.Imported( when (val sessions = importPcSessions(directory)) {
imported = null -> when (val equipment = importPcEquipmentAssociations(directory)) {
result.imported, null -> CatalogInboxResult.Imported(
reconciled = imported = result.imported,
result.reconciled, reconciled = result.reconciled,
skipped = skipped = result.skipped,
result.skipped,
) )
else -> CatalogInboxResult.Error(equipment)
}
else -> CatalogInboxResult.Error(sessions)
}
is PcCatalogImportResult.Invalid -> is PcCatalogImportResult.Invalid ->
CatalogInboxResult.Error( CatalogInboxResult.Error(
@ -166,6 +169,38 @@ class SyncCatalogInbox(
} }
} }
private fun importPcSessions(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null
return try {
val json = appContext.contentResolver.openInputStream(file.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture snapshot séances V2 impossible."
when (val result = repository.applyPcMobileExportV2Json(json)) {
is MobileSessionImportResult.Applied -> null
is MobileSessionImportResult.Invalid -> result.message
MobileSessionImportResult.DatabaseError -> "Erreur base locale séances V2."
}
} catch (error: Exception) { error.message ?: "Import séances V2 impossible." }
}
private fun importPcEquipmentAssociations(directory: DocumentFile): String? {
val file = directory.findFile("trainlog-equipment-associations-v2.json")
?: directory.findFile("trainlog-equipment-associations-v1.json")
?: return null /* Historic PC sync: absence means no information. */
return try {
val json = appContext.contentResolver.openInputStream(file.uri)
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
?: return "Lecture extension équipement impossible."
when (val result = repository.applyPcEquipmentAssociationsJson(json)) {
is EquipmentAssociationImportResult.Applied -> null
is EquipmentAssociationImportResult.Invalid -> result.message
EquipmentAssociationImportResult.DatabaseError -> "Erreur base locale équipement."
}
} catch (error: Exception) {
error.message ?: "Import extension équipement impossible."
}
}
fun readSyncReceipt( fun readSyncReceipt(
requestId: String, requestId: String,
): SyncReceiptResult { ): SyncReceiptResult {

View file

@ -39,8 +39,9 @@ class SyncExporter(
return SyncExportResult.Unsupported return SyncExportResult.Unsupported
} }
val json = /* V2 is the authoritative mobile session exchange. V1 remains
repository.buildMobileExportJson() * readable by desktop for historic devices but is not published here. */
val json = repository.buildMobileExportV2Json()
val bytes = val bytes =
json.toByteArray( json.toByteArray(
@ -62,7 +63,7 @@ class SyncExporter(
"/Trainlog/" "/Trainlog/"
val displayName = val displayName =
"trainlog-mobile-export-v1.json" "trainlog-mobile-export-v2.json"
val existing = val existing =
findExisting( findExisting(
@ -170,6 +171,10 @@ class SyncExporter(
) )
} }
val companionError = writeEquipmentAssociations()
if (companionError != null) {
return SyncExportResult.Error(companionError)
}
return SyncExportResult.Exported( return SyncExportResult.Exported(
displayPath = displayPath =
"Download/Trainlog/" + "Download/Trainlog/" +
@ -195,6 +200,35 @@ class SyncExporter(
} }
} }
/** Publish the companion separately so frozen mobile-export-v1 stays byte-compatible. */
private fun writeEquipmentAssociations(): String? {
val resolver = appContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val relativePath = Environment.DIRECTORY_DOWNLOADS + "/Trainlog/"
val name = "trainlog-equipment-associations-v2.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 de l'extension équipement impossible."
return try {
resolver.openOutputStream(uri, "wt")?.use {
it.write(repository.buildEquipmentAssociationsJson().toByteArray(Charsets.UTF_8))
it.flush()
} ?: return "Écriture de l'extension équipement 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 extension équipement impossible."
}
}
private fun findExisting( private fun findExisting(
collection: Uri, collection: Uri,
displayName: String, displayName: String,

View file

@ -67,6 +67,25 @@ sealed interface PcCatalogImportResult {
PcCatalogImportResult PcCatalogImportResult
} }
sealed interface EquipmentAssociationImportResult {
data class Applied(val updated: Int) : EquipmentAssociationImportResult
data class Invalid(val message: String) : EquipmentAssociationImportResult
data object DatabaseError : EquipmentAssociationImportResult
}
sealed interface MobileSessionImportResult {
data class Applied(val sessions: Int) : MobileSessionImportResult
data class Invalid(val message: String) : MobileSessionImportResult
data object DatabaseError : MobileSessionImportResult
}
sealed interface CreateEquipmentResult {
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
data object Invalid : CreateEquipmentResult
data object Conflict : CreateEquipmentResult
data class DatabaseError(val message: String) : CreateEquipmentResult
}
sealed interface SaveBodyObservationResult { sealed interface SaveBodyObservationResult {
data class Saved( data class Saved(
val observationId: String, val observationId: String,
@ -131,9 +150,10 @@ class TrainlogRepository(
databaseName: String = databaseName: String =
ANDROID_DATABASE_NAME, ANDROID_DATABASE_NAME,
) { ) {
private val applicationContext = context.applicationContext
private val database = private val database =
TrainlogDatabaseHelper( TrainlogDatabaseHelper(
context.applicationContext, applicationContext,
databaseName, databaseName,
) )
@ -141,6 +161,61 @@ class TrainlogRepository(
database.close() database.close()
} }
fun listEquipment(): List<EquipmentCatalogEntry> {
val output = mutableListOf<EquipmentCatalogEntry>()
database.readableDatabase.query(
"equipment",
arrayOf("equipment_id", "label_name", "display_name", "equipment_type", "load_semantics"),
null, null, null, null, "display_name COLLATE NOCASE, equipment_id",
).use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val aliases = mutableListOf<String>()
database.readableDatabase.rawQuery(
"SELECT alias FROM equipment_aliases ea JOIN equipment e ON e.id = ea.equipment_row_id WHERE e.equipment_id = ? ORDER BY alias COLLATE NOCASE;",
arrayOf(id),
).use { aliasCursor -> while (aliasCursor.moveToNext()) aliases += aliasCursor.getString(0) }
output += EquipmentCatalogEntry(
equipmentId = id,
labelName = cursor.getString(1),
displayName = cursor.getString(2),
aliases = aliases,
type = cursor.getString(3),
loadSemantics = EquipmentLoadSemantics.valueOf(cursor.getString(4).uppercase(Locale.ROOT)),
)
}
}
return output
}
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
EquipmentCatalog.search(listEquipment(), query)
/** Custom equipment is local user data, not an edit to the bundled manifest. */
fun createCustomEquipment(name: String): CreateEquipmentResult {
val displayName = name.trim().replace(Regex("\\s+"), " ")
if (displayName.isBlank() || displayName.length > 120) return CreateEquipmentResult.Invalid
return try {
val db = database.writableDatabase
val duplicate = db.rawQuery(
"SELECT 1 FROM equipment WHERE lower(display_name) = lower(?) LIMIT 1;", arrayOf(displayName),
).use { it.moveToFirst() }
if (duplicate) return CreateEquipmentResult.Conflict
val entry = EquipmentCatalogEntry(
equipmentId = "eq_" + UUID.randomUUID(), labelName = "", displayName = displayName,
aliases = emptyList(), type = "custom_machine", loadSemantics = EquipmentLoadSemantics.EXTERNAL,
)
val values = ContentValues().apply {
put("equipment_id", entry.equipmentId); put("label_name", entry.labelName)
put("display_name", entry.displayName); put("equipment_type", entry.type)
put("load_semantics", "external")
}
db.insertOrThrow("equipment", null, values)
CreateEquipmentResult.Created(entry)
} catch (error: SQLiteConstraintException) { CreateEquipmentResult.Conflict
} catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") }
}
fun listExercises(): List<ExerciseProfile> { fun listExercises(): List<ExerciseProfile> {
val output = val output =
mutableListOf<ExerciseProfile>() mutableListOf<ExerciseProfile>()
@ -439,9 +514,16 @@ class TrainlogRepository(
draft.exercises.any { draft.exercises.any {
!validateSessionExercise(it) !validateSessionExercise(it)
} || } ||
/* exercise_id identifies the catalogue movement. Repeated passages
* are valid; only their stable occurrence IDs must be unique. */
draft.exercises draft.exercises
.map { .map {
it.exercise.exerciseId it.entryId
}
.any { it.isBlank() } ||
draft.exercises
.map {
it.entryId
} }
.distinct() .distinct()
.size != .size !=
@ -691,6 +773,15 @@ class TrainlogRepository(
exerciseDraft.exercise exerciseDraft.exercise
.dataFields .dataFields
) )
put("entry_id", exerciseDraft.entryId)
val equipmentRowId = lookupEquipmentRowIdOrNull(
db,
exerciseDraft.equipmentId,
)
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
"Unknown equipment: ${exerciseDraft.equipmentId}"
}
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
} }
val sessionExerciseRowId = val sessionExerciseRowId =
@ -757,6 +848,7 @@ class TrainlogRepository(
"position", "position",
setIndex setIndex
) )
set.weightKg?.let { put("weight_kg", it) }
if ( if (
exerciseDraft.exercise exerciseDraft.exercise
@ -1198,6 +1290,26 @@ class TrainlogRepository(
if (cursor.moveToFirst()) cursor.getLong(0) else null if (cursor.moveToFirst()) cursor.getLong(0) else null
} }
private fun lookupEquipmentRowIdOrNull(
db: SQLiteDatabase,
equipmentId: String?,
): Long? {
if (equipmentId == null) {
return null
}
return db.query(
"equipment",
arrayOf("id"),
"equipment_id = ?",
arrayOf(equipmentId),
null,
null,
null,
).use { cursor ->
if (cursor.moveToFirst()) cursor.getLong(0) else null
}
}
private fun exerciseHasReferences( private fun exerciseHasReferences(
db: SQLiteDatabase, db: SQLiteDatabase,
exerciseRowId: Long, exerciseRowId: Long,
@ -1350,6 +1462,216 @@ class TrainlogRepository(
return root.toString() return root.toString()
} }
/**
* V2 is a new artifact, never a reinterpretation of frozen V1. Each
* session occurrence carries its durable Android entry_id, ordering,
* equipment and actual load so repeated catalogue exercises round-trip.
*/
fun buildMobileExportV2Json(): String {
val root = JSONObject(buildMobileExportJson())
root.put("version", 2)
val db = database.readableDatabase
val sessions = root.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val items = session.getJSONArray("exercises")
db.rawQuery(
"SELECT se.id,se.entry_id,se.position,eq.equipment_id FROM session_exercises se " +
"JOIN sessions s ON s.id=se.session_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " +
"WHERE s.session_id=? ORDER BY se.position ASC;",
arrayOf(session.getString("session_id")),
).use { entries ->
var itemIndex = 0
while (entries.moveToNext()) {
val item = items.getJSONObject(itemIndex++)
val rowId = entries.getLong(0)
item.put("entry_id", entries.getString(1))
item.put("position", entries.getInt(2))
if (entries.isNull(3)) item.put("equipment_id", JSONObject.NULL)
else item.put("equipment_id", entries.getString(3))
if (item.has("sets")) {
val sets = item.getJSONArray("sets")
db.query("performed_sets", arrayOf("weight_kg"),
"session_exercise_row_id=?", arrayOf(rowId.toString()), null, null, "position ASC").use { cursor ->
var setIndex = 0
while (cursor.moveToNext()) {
if (!cursor.isNull(0)) sets.getJSONObject(setIndex).put("weight_kg", cursor.getDouble(0))
setIndex++
}
}
}
}
}
}
return root.toString()
}
/** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */
fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult {
val root = try { JSONObject(json) } catch (_: Exception) {
return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide.")
}
if (root.optString("format") != "trainlog-mobile-export" || root.optInt("version", -1) != 2) {
return MobileSessionImportResult.Invalid("Snapshot séances V2 non supporté.")
}
val sessions = root.optJSONArray("sessions") ?: return MobileSessionImportResult.Invalid("Sessions manquantes.")
val db = database.writableDatabase
return try {
db.beginTransaction()
for (i in 0 until sessions.length()) {
val session = sessions.getJSONObject(i)
val sessionId = session.optString("session_id")
val startedAt = session.optString("started_at")
val type = session.optString("session_type")
val entries = session.optJSONArray("exercises")
if (sessionId.isBlank() || startedAt.isBlank() || type !in setOf("training", "max_test") || entries == null) {
return MobileSessionImportResult.Invalid("Session V2 invalide.")
}
val rowId = db.rawQuery("SELECT id FROM sessions WHERE session_id=?", arrayOf(sessionId)).use {
if (it.moveToFirst()) it.getLong(0) else null
} ?: run {
val values = ContentValues().apply { put("session_id", sessionId); put("started_at", startedAt); put("session_type", type) }
db.insertOrThrow("sessions", null, values)
}
/* V2 replaces only a same stable session; every entry is
* checked before insertion and entry_id remains global unique. */
db.delete("session_exercises", "session_row_id=?", arrayOf(rowId.toString()))
val seen = mutableSetOf<String>()
for (index in 0 until entries.length()) {
val entry = entries.getJSONObject(index)
val entryId = entry.optString("entry_id")
val exerciseId = entry.optString("exercise_id")
val position = entry.optInt("position", -1)
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
}
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
val recording = entry.optString("recording_mode")
val tracking = entry.optString("tracking_mode")
if (recording !in setOf("sets", "continuous") || tracking !in setOf("reps", "duration")) {
return MobileSessionImportResult.Invalid("Profil V2 invalide.")
}
val values = ContentValues().apply {
put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId)
put("position", position); put("recording_mode", recording); put("tracking_mode", tracking)
put("data_fields", entry.optInt("data_fields", 0)); put("equipment_row_id", lookupEquipmentRowIdOrNull(db, entry.optString("equipment_id").ifBlank { null }))
}
val occurrence = db.insertOrThrow("session_exercises", null, values)
if (recording == "continuous") {
val c = entry.optJSONObject("continuous") ?: return MobileSessionImportResult.Invalid("Activité continue manquante.")
db.insertOrThrow("continuous_activity", null, ContentValues().apply {
put("session_exercise_row_id", occurrence); put("duration_seconds", c.optInt("duration_seconds", 0))
if (c.has("speed_kmh")) put("speed_kmh", c.getDouble("speed_kmh")); if (c.has("distance_km")) put("distance_km", c.getDouble("distance_km"))
})
} else {
val sets = entry.optJSONArray("sets") ?: return MobileSessionImportResult.Invalid("Séries manquantes.")
for (setIndex in 0 until sets.length()) {
val set = sets.getJSONObject(setIndex)
db.insertOrThrow("performed_sets", null, ContentValues().apply {
put("session_exercise_row_id", occurrence); put("position", setIndex)
if (tracking == "reps") put("reps", set.optInt("reps", -1)) else put("duration_seconds", set.optInt("duration_seconds", 0))
if (set.has("weight_kg")) put("weight_kg", set.getDouble("weight_kg"))
})
}
}
}
}
db.setTransactionSuccessful()
MobileSessionImportResult.Applied(sessions.length())
} catch (_: Exception) { MobileSessionImportResult.DatabaseError
} finally { db.endTransaction() }
}
/**
* CONTRACT: this companion artifact is deliberately outside frozen mobile
* export v1. `(session_id, exercise_id)` is stable and unambiguous under
* the one-entry-per-exercise-per-session model. `cleared` is intentional
* state, unlike an absent v1 artifact which conveys no equipment signal.
*/
fun buildEquipmentAssociationsJson(): String {
val root = JSONObject()
.put("format", "trainlog-equipment-associations")
.put("version", 2)
.put("generated_at", OffsetDateTime.now().toString())
val associations = JSONArray()
database.readableDatabase.rawQuery(
"SELECT s.session_id, se.entry_id, e.exercise_id, eq.equipment_id FROM session_exercises se " +
"JOIN sessions s ON s.id = se.session_row_id " +
"JOIN exercises e ON e.id = se.exercise_row_id " +
"LEFT JOIN equipment eq ON eq.id = se.equipment_row_id " +
"ORDER BY s.started_at ASC, s.id ASC, se.position ASC;",
null,
).use { cursor ->
while (cursor.moveToNext()) {
val item = JSONObject()
.put("session_id", cursor.getString(0))
.put("entry_id", cursor.getString(1))
.put("exercise_id", cursor.getString(2))
if (cursor.isNull(3)) item.put("state", "cleared") else {
item.put("state", "set")
item.put("equipment_id", cursor.getString(3))
}
associations.put(item)
}
}
return root.put("associations", associations).toString()
}
fun applyPcEquipmentAssociationsJson(json: String): EquipmentAssociationImportResult {
val root = try { JSONObject(json) } catch (_: Exception) {
return EquipmentAssociationImportResult.Invalid("Extension équipement JSON invalide.")
}
val version = root.optInt("version", -1)
if (root.optString("format") != "trainlog-equipment-associations" || version !in setOf(1, 2)) {
return EquipmentAssociationImportResult.Invalid("Extension équipement non supportée.")
}
val items = root.optJSONArray("associations")
?: return EquipmentAssociationImportResult.Invalid("Associations équipement manquantes.")
val known = listEquipment().map { it.equipmentId }.toSet()
val db = database.writableDatabase
var updated = 0
db.beginTransaction()
try {
for (index in 0 until items.length()) {
val item = items.getJSONObject(index)
val sessionId = item.optString("session_id")
val exerciseId = item.optString("exercise_id")
val entryId = if (version == 2) item.optString("entry_id") else null
val state = item.optString("state")
if (sessionId.isBlank() || exerciseId.isBlank() || state !in setOf("set", "cleared") || (version == 2 && entryId.isNullOrBlank())) {
return EquipmentAssociationImportResult.Invalid("Association équipement invalide.")
}
val equipmentId = if (state == "set") item.optString("equipment_id") else null
if (state == "set" && (equipmentId.isNullOrBlank() || equipmentId !in known)) {
return EquipmentAssociationImportResult.Invalid("Équipement inconnu : $equipmentId")
}
val row = db.rawQuery(
if (version == 2) {
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;"
} else {
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND e.exercise_id=?;"
},
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId),
).use { cursor ->
val first = if (cursor.moveToFirst()) cursor.getLong(0) else null
if (version == 1 && first != null && cursor.moveToNext()) null else first
}
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
val values = ContentValues()
if (equipmentId == null) values.putNull("equipment_row_id") else {
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)!!
values.put("equipment_row_id", equipmentRowId)
}
updated += db.update("session_exercises", values, "id = ?", arrayOf(row.toString()))
}
db.setTransactionSuccessful()
return EquipmentAssociationImportResult.Applied(updated)
} catch (_: Exception) {
return EquipmentAssociationImportResult.DatabaseError
} finally { db.endTransaction() }
}
fun saveBodyObservation( fun saveBodyObservation(
draft: BodyObservationDraft, draft: BodyObservationDraft,
): SaveBodyObservationResult { ): SaveBodyObservationResult {
@ -1587,15 +1909,20 @@ class TrainlogRepository(
""" """
SELECT SELECT
se.id, se.id,
se.entry_id,
e.exercise_id,
e.name, e.name,
se.recording_mode, se.recording_mode,
se.tracking_mode, se.tracking_mode,
se.data_fields se.data_fields,
eq.display_name
FROM session_exercises AS se FROM session_exercises AS se
JOIN sessions AS s JOIN sessions AS s
ON s.id = se.session_row_id ON s.id = se.session_row_id
JOIN exercises AS e JOIN exercises AS e
ON e.id = se.exercise_row_id ON e.id = se.exercise_row_id
LEFT JOIN equipment AS eq
ON eq.id = se.equipment_row_id
WHERE s.session_id = ? WHERE s.session_id = ?
ORDER BY se.position ASC; ORDER BY se.position ASC;
""".trimIndent(), """.trimIndent(),
@ -1605,12 +1932,13 @@ class TrainlogRepository(
val sessionExerciseRowId = val sessionExerciseRowId =
cursor.getLong(0) cursor.getLong(0)
val name = val entryId = cursor.getString(1)
cursor.getString(1) val exerciseId = cursor.getString(2)
val name = cursor.getString(3)
val recording = val recording =
when ( when (
cursor.getString(2) cursor.getString(4)
) { ) {
"continuous" -> "continuous" ->
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
@ -1621,7 +1949,7 @@ class TrainlogRepository(
val tracking = val tracking =
when ( when (
cursor.getString(3) cursor.getString(5)
) { ) {
"duration" -> "duration" ->
TrackingMode.DURATION TrackingMode.DURATION
@ -1631,7 +1959,8 @@ class TrainlogRepository(
} }
val dataFields = val dataFields =
cursor.getInt(4) cursor.getInt(6)
val equipmentDisplayName = if (cursor.isNull(7)) null else cursor.getString(7)
if ( if (
recording == recording ==
@ -1666,8 +1995,11 @@ class TrainlogRepository(
exercises += exercises +=
SessionExerciseDetail( SessionExerciseDetail(
entryId = entryId,
exerciseId = exerciseId,
exerciseName = exerciseName =
name, name,
equipmentDisplayName = equipmentDisplayName,
recordingMode = recordingMode =
recording, recording,
trackingMode = trackingMode =
@ -1710,6 +2042,7 @@ class TrainlogRepository(
arrayOf( arrayOf(
"reps", "reps",
"duration_seconds", "duration_seconds",
"weight_kg",
), ),
"session_exercise_row_id = ?", "session_exercise_row_id = ?",
arrayOf( arrayOf(
@ -1746,14 +2079,18 @@ class TrainlogRepository(
setCursor setCursor
.getInt(1) .getInt(1)
}, },
weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2),
) )
} }
} }
exercises += exercises +=
SessionExerciseDetail( SessionExerciseDetail(
entryId = entryId,
exerciseId = exerciseId,
exerciseName = exerciseName =
name, name,
equipmentDisplayName = equipmentDisplayName,
recordingMode = recordingMode =
recording, recording,
trackingMode = trackingMode =
@ -1772,6 +2109,26 @@ class TrainlogRepository(
) )
} }
fun setCompletedSessionEquipment(
sessionId: String,
entryId: String,
equipmentId: String?,
): Boolean {
val db = database.writableDatabase
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)
if (equipmentId != null && equipmentRowId == null) return false
return try {
val values = ContentValues()
if (equipmentRowId == null) values.putNull("equipment_row_id") else values.put("equipment_row_id", equipmentRowId)
db.update(
"session_exercises", values,
"id = (SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id " +
"WHERE s.session_id=? AND se.entry_id=?)",
arrayOf(sessionId, entryId),
) == 1
} catch (_: Exception) { false }
}
private fun loadActiveSessionDraft( private fun loadActiveSessionDraft(
db: SQLiteDatabase, db: SQLiteDatabase,
): ActiveDraftRestore? { ): ActiveDraftRestore? {
@ -1787,6 +2144,7 @@ class TrainlogRepository(
d.distance_text, d.distance_text,
d.updated_at, d.updated_at,
d.selected_exercise_label, d.selected_exercise_label,
d.selected_equipment_id,
e.exercise_id, e.exercise_id,
e.name, e.name,
e.normalized_name, e.normalized_name,
@ -1804,15 +2162,15 @@ class TrainlogRepository(
null null
} else { } else {
val missingSelection = val missingSelection =
cursor.isNull(8) && cursor.isNull(9) &&
!cursor.isNull(7) !cursor.isNull(7)
val selected = val selected =
if (cursor.isNull(8)) { if (cursor.isNull(9)) {
null null
} else { } else {
exerciseProfileFromCursor( exerciseProfileFromCursor(
cursor, cursor,
8, 9,
) )
} }
@ -1823,6 +2181,7 @@ class TrainlogRepository(
), ),
form = SessionDraftForm( form = SessionDraftForm(
selectedExercise = selected, selectedExercise = selected,
selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8),
setCountText = cursor.getString(1), setCountText = cursor.getString(1),
repsText = cursor.getString(2), repsText = cursor.getString(2),
durationText = cursor.getString(3), durationText = cursor.getString(3),
@ -1856,10 +2215,14 @@ class TrainlogRepository(
e.normalized_name, e.normalized_name,
de.recording_mode, de.recording_mode,
de.tracking_mode, de.tracking_mode,
de.data_fields de.data_fields,
eq.equipment_id,
de.entry_id
FROM draft_session_exercises AS de FROM draft_session_exercises AS de
JOIN exercises AS e JOIN exercises AS e
ON e.id = de.exercise_row_id ON e.id = de.exercise_row_id
LEFT JOIN equipment AS eq
ON eq.id = de.equipment_row_id
WHERE de.draft_id = ? WHERE de.draft_id = ?
ORDER BY de.position ASC; ORDER BY de.position ASC;
""".trimIndent(), """.trimIndent(),
@ -1885,6 +2248,8 @@ class TrainlogRepository(
dataFields = dataFields =
cursor.getInt(6), cursor.getInt(6),
) )
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
val entryId = cursor.getString(8)
if ( if (
exercise.recordingMode == exercise.recordingMode ==
@ -1909,7 +2274,9 @@ class TrainlogRepository(
} }
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId,
exercise = exercise, exercise = exercise,
equipmentId = equipmentId,
continuousDurationSeconds = continuousDurationSeconds =
item.getInt(0), item.getInt(0),
speedKmh = speedKmh =
@ -1935,6 +2302,7 @@ class TrainlogRepository(
arrayOf( arrayOf(
"reps", "reps",
"duration_seconds", "duration_seconds",
"weight_kg",
), ),
"draft_exercise_row_id = ?", "draft_exercise_row_id = ?",
arrayOf(rowId.toString()), arrayOf(rowId.toString()),
@ -1957,12 +2325,15 @@ class TrainlogRepository(
} else { } else {
setCursor.getInt(1) setCursor.getInt(1)
}, },
weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2),
) )
} }
} }
exercises += exercises +=
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId,
exercise = exercise, exercise = exercise,
equipmentId = equipmentId,
sets = sets, sets = sets,
) )
} }
@ -1996,6 +2367,7 @@ class TrainlogRepository(
val values = val values =
ContentValues().apply { ContentValues().apply {
put("session_type", draft.sessionType.wireValue) put("session_type", draft.sessionType.wireValue)
putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId)
if (selectedRowId == null) { if (selectedRowId == null) {
putNull("selected_exercise_row_id") putNull("selected_exercise_row_id")
putNull("selected_exercise_label") putNull("selected_exercise_label")
@ -2064,6 +2436,15 @@ class TrainlogRepository(
"data_fields", "data_fields",
exerciseDraft.exercise.dataFields, exerciseDraft.exercise.dataFields,
) )
put("entry_id", exerciseDraft.entryId)
val equipmentRowId = lookupEquipmentRowIdOrNull(
db,
exerciseDraft.equipmentId,
)
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
"Unknown equipment: ${exerciseDraft.equipmentId}"
}
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
} }
val draftExerciseRowId = val draftExerciseRowId =
db.insertOrThrow( db.insertOrThrow(
@ -2109,6 +2490,7 @@ class TrainlogRepository(
draftExerciseRowId, draftExerciseRowId,
) )
put("position", setIndex) put("position", setIndex)
set.weightKg?.let { put("weight_kg", it) }
if ( if (
exerciseDraft.exercise.trackingMode == exerciseDraft.exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
@ -2175,6 +2557,15 @@ class TrainlogRepository(
exerciseDraft.exercise.trackingMode.wireValue, exerciseDraft.exercise.trackingMode.wireValue,
) )
put("data_fields", exerciseDraft.exercise.dataFields) put("data_fields", exerciseDraft.exercise.dataFields)
put("entry_id", exerciseDraft.entryId)
val equipmentRowId = lookupEquipmentRowIdOrNull(
db,
exerciseDraft.equipmentId,
)
check(exerciseDraft.equipmentId == null || equipmentRowId != null) {
"Unknown equipment: ${exerciseDraft.equipmentId}"
}
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
} }
val sessionExerciseRowId = val sessionExerciseRowId =
db.insertOrThrow( db.insertOrThrow(
@ -2210,6 +2601,7 @@ class TrainlogRepository(
ContentValues().apply { ContentValues().apply {
put("session_exercise_row_id", sessionExerciseRowId) put("session_exercise_row_id", sessionExerciseRowId)
put("position", setIndex) put("position", setIndex)
set.weightKg?.let { put("weight_kg", it) }
if ( if (
exerciseDraft.exercise.trackingMode == exerciseDraft.exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
@ -2407,19 +2799,32 @@ private fun ContentValues.putOptionalDouble(
} }
} }
private fun ContentValues.putOptionalString(
key: String,
value: String?,
) {
if (value == null) putNull(key) else put(key, value)
}
private fun equipmentAliasNormalize(value: String): String =
Normalizer.normalize(value, Normalizer.Form.NFD)
.replace("\\p{M}+".toRegex(), "")
.lowercase(Locale.ROOT)
.trim()
private const val ANDROID_DATABASE_NAME = private const val ANDROID_DATABASE_NAME =
"trainlog-android.db" "trainlog-android.db"
private const val ACTIVE_DRAFT_ID = 1 private const val ACTIVE_DRAFT_ID = 1
private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096
private class TrainlogDatabaseHelper( private class TrainlogDatabaseHelper(
context: Context, private val appContext: Context,
databaseName: String, databaseName: String,
) : SQLiteOpenHelper( ) : SQLiteOpenHelper(
context, appContext,
databaseName, databaseName,
null, null,
4, 7,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -2438,6 +2843,8 @@ private class TrainlogDatabaseHelper(
createSessionTables(db) createSessionTables(db)
createBodyTable(db) createBodyTable(db)
createActiveDraftTables(db) createActiveDraftTables(db)
createEquipmentTables(db)
seedEquipment(db)
} }
override fun onUpgrade( override fun onUpgrade(
@ -2464,6 +2871,41 @@ private class TrainlogDatabaseHelper(
version = 4 version = 4
} }
if (version < 5 && newVersion >= 5) {
/* CONTRACT: v5 adds only canonical equipment metadata. Existing
* exercises, historical rows, and drafts are never rewritten. */
createEquipmentTables(db)
seedEquipment(db)
addEquipmentReferenceColumns(db)
version = 5
}
if (version < 6 && newVersion >= 6) {
/* v6 keeps historic sets intact while allowing optional per-set load. */
db.execSQL("ALTER TABLE performed_sets ADD COLUMN weight_kg REAL CHECK(weight_kg >= 0.0);")
db.execSQL("ALTER TABLE draft_performed_sets ADD COLUMN weight_kg REAL CHECK(weight_kg >= 0.0);")
version = 6
}
if (version < 7 && newVersion >= 7) {
/* WHY: exercise_id is a catalogue identity, not an occurrence identity.
* Historic rows get the deterministic same cross-device legacy key. */
db.execSQL("ALTER TABLE session_exercises ADD COLUMN entry_id TEXT;")
db.execSQL("UPDATE session_exercises SET entry_id = 'sxe_legacy_' || (SELECT session_id FROM sessions WHERE sessions.id = session_exercises.session_row_id) || '_' || (SELECT exercise_id FROM exercises WHERE exercises.id = session_exercises.exercise_row_id);")
db.execSQL("CREATE UNIQUE INDEX session_exercises_entry_id_v7 ON session_exercises(entry_id);")
db.execSQL("ALTER TABLE draft_performed_sets RENAME TO draft_performed_sets_v6;")
db.execSQL("ALTER TABLE draft_continuous_activity RENAME TO draft_continuous_activity_v6;")
db.execSQL("ALTER TABLE draft_session_exercises RENAME TO draft_session_exercises_v6;")
createActiveDraftTables(db)
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) SELECT id,draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,'sxe_draft_legacy_' || id FROM draft_session_exercises_v6;")
db.execSQL("INSERT INTO draft_performed_sets(id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg) SELECT id,draft_exercise_row_id,position,reps,duration_seconds,weight_kg FROM draft_performed_sets_v6;")
db.execSQL("INSERT INTO draft_continuous_activity(id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km) SELECT id,draft_exercise_row_id,duration_seconds,speed_kmh,distance_km FROM draft_continuous_activity_v6;")
db.execSQL("DROP TABLE draft_performed_sets_v6;")
db.execSQL("DROP TABLE draft_continuous_activity_v6;")
db.execSQL("DROP TABLE draft_session_exercises_v6;")
version = 7
}
if (version != newVersion) { if (version != newVersion) {
error( error(
"Unsupported Android DB upgrade " + "Unsupported Android DB upgrade " +
@ -2569,6 +3011,10 @@ private class TrainlogDatabaseHelper(
data_fields >= 0 AND data_fields >= 0 AND
(data_fields & ~3) = 0 (data_fields & ~3) = 0
), ),
equipment_row_id INTEGER
REFERENCES equipment(id)
ON DELETE SET NULL,
entry_id TEXT NOT NULL UNIQUE,
UNIQUE( UNIQUE(
session_row_id, session_row_id,
position position
@ -2590,6 +3036,8 @@ private class TrainlogDatabaseHelper(
CHECK(reps >= 0), CHECK(reps >= 0),
duration_seconds INTEGER duration_seconds INTEGER
CHECK(duration_seconds > 0), CHECK(duration_seconds > 0),
weight_kg REAL
CHECK(weight_kg >= 0.0),
CHECK( CHECK(
( (
reps IS NOT NULL AND reps IS NOT NULL AND
@ -2705,6 +3153,7 @@ private class TrainlogDatabaseHelper(
REFERENCES exercises(id) REFERENCES exercises(id)
ON DELETE SET NULL, ON DELETE SET NULL,
selected_exercise_label TEXT, selected_exercise_label TEXT,
selected_equipment_id TEXT,
set_count_text TEXT NOT NULL, set_count_text TEXT NOT NULL,
reps_text TEXT NOT NULL, reps_text TEXT NOT NULL,
duration_text TEXT NOT NULL, duration_text TEXT NOT NULL,
@ -2746,8 +3195,11 @@ private class TrainlogDatabaseHelper(
data_fields >= 0 AND data_fields >= 0 AND
(data_fields & ~3) = 0 (data_fields & ~3) = 0
), ),
UNIQUE(draft_id, position), equipment_row_id INTEGER
UNIQUE(draft_id, exercise_row_id) REFERENCES equipment(id)
ON DELETE SET NULL,
entry_id TEXT NOT NULL UNIQUE,
UNIQUE(draft_id, position)
); );
""".trimIndent() """.trimIndent()
) )
@ -2768,6 +3220,8 @@ private class TrainlogDatabaseHelper(
CHECK(reps >= 0), CHECK(reps >= 0),
duration_seconds INTEGER duration_seconds INTEGER
CHECK(duration_seconds > 0), CHECK(duration_seconds > 0),
weight_kg REAL
CHECK(weight_kg >= 0.0),
CHECK( CHECK(
( (
reps IS NOT NULL AND reps IS NOT NULL AND
@ -2799,4 +3253,137 @@ private class TrainlogDatabaseHelper(
""".trimIndent() """.trimIndent()
) )
} }
private fun createEquipmentTables(
db: SQLiteDatabase,
) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS equipment(
id INTEGER PRIMARY KEY,
equipment_id TEXT NOT NULL UNIQUE,
label_name TEXT NOT NULL,
display_name TEXT NOT NULL,
equipment_type TEXT NOT NULL,
load_semantics TEXT NOT NULL
CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio'))
);
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS equipment_aliases(
equipment_row_id INTEGER NOT NULL REFERENCES equipment(id) ON DELETE CASCADE,
alias TEXT NOT NULL,
normalized_alias TEXT NOT NULL,
UNIQUE(equipment_row_id, normalized_alias)
);
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS exercise_equipment(
exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,
equipment_row_id INTEGER NOT NULL REFERENCES equipment(id) ON DELETE RESTRICT,
UNIQUE(exercise_row_id, equipment_row_id)
);
""".trimIndent(),
)
/* WHY: these are logical catalogue capabilities, including exercises
* which need not yet exist in a user's mutable exercise catalogue. */
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS catalog_exercise_equipment(
exercise_id TEXT NOT NULL,
equipment_row_id INTEGER NOT NULL
REFERENCES equipment(id)
ON DELETE RESTRICT,
load_semantics TEXT NOT NULL
CHECK(load_semantics IN ('external', 'assistance', 'bodyweight', 'cardio')),
PRIMARY KEY(exercise_id, equipment_row_id)
);
""".trimIndent(),
)
}
private fun addEquipmentReferenceColumns(
db: SQLiteDatabase,
) {
/* CONTRACT: v5 retains all historic/draft rows unchanged; equipment
* is optional because it was not captured before this version. */
db.execSQL(
"ALTER TABLE session_exercises ADD COLUMN equipment_row_id INTEGER " +
"REFERENCES equipment(id) ON DELETE SET NULL;",
)
db.execSQL(
"ALTER TABLE draft_session_exercises ADD COLUMN equipment_row_id INTEGER " +
"REFERENCES equipment(id) ON DELETE SET NULL;",
)
db.execSQL(
"ALTER TABLE active_session_draft ADD COLUMN selected_equipment_id TEXT;",
)
}
private fun seedEquipment(
db: SQLiteDatabase,
) {
/* INVARIANT: INSERT OR IGNORE makes application startup idempotent;
* canonical IDs, rather than display text, are the persistent keys. */
EquipmentCatalog.load(appContext).forEach { entry ->
db.execSQL(
"""
INSERT OR IGNORE INTO equipment(
equipment_id, label_name, display_name, equipment_type, load_semantics
) VALUES(?, ?, ?, ?, ?);
""".trimIndent(),
arrayOf(
entry.equipmentId,
entry.labelName,
entry.displayName,
entry.type,
entry.loadSemantics.name.lowercase(),
),
)
db.rawQuery(
"SELECT id FROM equipment WHERE equipment_id = ?;",
arrayOf(entry.equipmentId),
).use { cursor ->
check(cursor.moveToFirst())
val rowId = cursor.getLong(0)
entry.aliases.forEach { alias ->
db.execSQL(
"""
INSERT OR IGNORE INTO equipment_aliases(
equipment_row_id, alias, normalized_alias
) VALUES(?, ?, ?);
""".trimIndent(),
arrayOf<Any>(
rowId,
alias,
equipmentAliasNormalize(alias),
),
)
}
}
}
EquipmentCatalog.exerciseEquipmentRelations(appContext).forEach { relation ->
db.rawQuery(
"SELECT id FROM equipment WHERE equipment_id = ?;",
arrayOf(relation.equipmentId),
).use { cursor ->
check(cursor.moveToFirst()) {
"Seeded equipment missing: ${relation.equipmentId}"
}
db.execSQL(
"INSERT OR IGNORE INTO catalog_exercise_equipment(" +
"exercise_id, equipment_row_id, load_semantics) VALUES(?, ?, ?);",
arrayOf<Any>(
relation.exerciseId,
cursor.getLong(0),
relation.loadSemantics.name.lowercase(Locale.ROOT),
),
)
}
}
}
} }

View file

@ -21,10 +21,15 @@ enum class SessionType(
data class SessionSetDraft( data class SessionSetDraft(
val reps: Int = 0, val reps: Int = 0,
val durationSeconds: Int = 0, val durationSeconds: Int = 0,
val weightKg: Double? = null,
) )
data class SessionExerciseDraft( data class SessionExerciseDraft(
/** Stable occurrence identity; exercise_id identifies only the catalogue movement. */
val entryId: String = "sxe_" + java.util.UUID.randomUUID().toString(),
val exercise: ExerciseProfile, val exercise: ExerciseProfile,
/** Stable canonical equipment ID selected for this occurrence, if any. */
val equipmentId: String? = null,
val sets: List<SessionSetDraft> = emptyList(), val sets: List<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0, val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null, val speedKmh: Double? = null,
@ -38,8 +43,13 @@ data class SessionDraft(
data class SessionDraftForm( data class SessionDraftForm(
val selectedExercise: ExerciseProfile? = null, val selectedExercise: ExerciseProfile? = null,
/** Null means creation; otherwise replace this durable draft entry. */
val editingExerciseIndex: Int? = null,
val editingEntryId: String? = null,
val selectedEquipmentId: String? = null,
val setCountText: String = "3", val setCountText: String = "3",
val repsText: String = "3x10", val repsText: String = "3x10",
val weightText: String = "",
val durationText: String = "30", val durationText: String = "30",
val speedText: String = "", val speedText: String = "",
val distanceText: String = "", val distanceText: String = "",
@ -65,7 +75,11 @@ data class SessionSummary(
) )
data class SessionExerciseDetail( data class SessionExerciseDetail(
/** Stable completed-session occurrence identity, never catalogue identity. */
val entryId: String,
val exerciseId: String,
val exerciseName: String, val exerciseName: String,
val equipmentDisplayName: String? = null,
val recordingMode: RecordingMode, val recordingMode: RecordingMode,
val trackingMode: TrackingMode, val trackingMode: TrackingMode,
val dataFields: Int, val dataFields: Int,

View file

@ -2,6 +2,9 @@ package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
@ -19,8 +22,12 @@ fun SessionDetailScreen(
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
var revision by remember(sessionId) { mutableStateOf(0) }
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
var equipmentQuery by remember(sessionId) { mutableStateOf("") }
val detail = val detail =
remember(sessionId) { remember(sessionId, revision) {
sessionId?.let { sessionId?.let {
repository.getSessionDetail( repository.getSessionDetail(
it it
@ -97,6 +104,45 @@ fun SessionDetailScreen(
title = title =
"${index + 1}. ${exercise.exerciseName}" "${index + 1}. ${exercise.exerciseName}"
) { ) {
exercise.equipmentDisplayName?.let { equipment ->
TrainlogInfo("Équipement : $equipment", color = colors.muted)
}
if (editingEntryId == exercise.entryId) {
TrainlogInputField(
label = "Rechercher une machine",
value = equipmentQuery,
onValueChange = { equipmentQuery = it },
)
TrainlogAction(
label = "Retirer l'équipement",
description = "Conserver l'exercice sans machine associée.",
accent = colors.warning,
onClick = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, null)) {
revision++; editingEntryId = null; equipmentQuery = ""
}
},
)
repository.searchEquipment(equipmentQuery).take(8).forEach { equipment ->
TrainlogAction(
label = equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = colors.success,
onClick = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId, exercise.entryId, equipment.equipmentId)) {
revision++; editingEntryId = null; equipmentQuery = ""
}
},
)
}
} else {
TrainlogAction(
label = "Modifier l'équipement",
description = "Choisir, remplacer ou retirer la machine de cette entrée.",
accent = colors.muted,
onClick = { editingEntryId = exercise.entryId },
)
}
if ( if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.CONTINUOUS RecordingMode.CONTINUOUS
@ -144,7 +190,13 @@ private fun SetsDetail(
exercise.trackingMode == exercise.trackingMode ==
TrackingMode.REPS TrackingMode.REPS
) { ) {
"Série ${index + 1} : ${set.reps} reps" buildString {
append("Série ${index + 1} : ${set.reps} reps")
set.weightKg?.let {
val rendered = "%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
append(" · $rendered kg")
}
}
} else { } else {
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}" "Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
} }

View file

@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.ActiveDraftLoadResult import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.FinalizeActiveDraftResult import com.labfytools.trainlog.data.FinalizeActiveDraftResult
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.ActiveSessionDraft
@ -275,6 +277,20 @@ fun SessionScreen(
colors.text, colors.text,
) )
TrainlogAction(
label = "Modifier ${draft.exercise.name}",
description = "Corriger cet exercice sans le supprimer de la séance.",
accent = colors.accent,
onClick = {
persistDraft(
currentDraft.copy(
form = formForExistingExercise(draft, index),
),
null,
)
},
)
TrainlogAction( TrainlogAction(
label = label =
"Retirer ${draft.exercise.name}", "Retirer ${draft.exercise.name}",
@ -330,12 +346,9 @@ fun SessionScreen(
.selectedExercise .selectedExercise
?.exerciseId == ?.exerciseId ==
exercise.exerciseId, exercise.exerciseId,
disabled = disabled = false,
alreadyAdded,
onClick = { onClick = {
if ( if (true) {
!alreadyAdded
) {
persistDraft( persistDraft(
currentDraft.copy( currentDraft.copy(
form = form =
@ -360,6 +373,7 @@ fun SessionScreen(
SessionExerciseForm( SessionExerciseForm(
key = key =
editingExercise.exerciseId, editingExercise.exerciseId,
repository = repository,
exercise = exercise =
editingExercise, editingExercise,
initialForm = initialForm =
@ -368,7 +382,10 @@ fun SessionScreen(
form -> form ->
persistDraft( persistDraft(
currentDraft.copy( currentDraft.copy(
form = form form = form.copy(
editingExerciseIndex = currentDraft.form.editingExerciseIndex,
editingEntryId = currentDraft.form.editingEntryId,
)
), ),
null, null,
) )
@ -384,15 +401,18 @@ fun SessionScreen(
}, },
onAdd = { onAdd = {
draft -> draft ->
val editIndex = currentDraft.form.editingExerciseIndex
persistDraft( persistDraft(
currentDraft.copy( currentDraft.copy(
exercises = exercises = editIndex?.let { replacingIndex ->
currentDraft.exercises + currentDraft.exercises.mapIndexed { index, existing ->
draft, if (index == replacingIndex) draft else existing
}
} ?: (currentDraft.exercises + draft),
form = form =
SessionDraftForm(), SessionDraftForm(),
), ),
"Exercice ajouté à la séance.", if (editIndex == null) "Exercice ajouté à la séance." else "Exercice modifié.",
) )
}, },
) )
@ -594,6 +614,7 @@ private fun CatalogChoice(
@Composable @Composable
private fun SessionExerciseForm( private fun SessionExerciseForm(
key: String, key: String,
repository: TrainlogRepository,
exercise: ExerciseProfile, exercise: ExerciseProfile,
initialForm: SessionDraftForm, initialForm: SessionDraftForm,
onFormChanged: (SessionDraftForm) -> Unit, onFormChanged: (SessionDraftForm) -> Unit,
@ -619,6 +640,13 @@ private fun SessionExerciseForm(
) )
} }
var weightText by
remember(key) {
mutableStateOf(
initialForm.weightText
)
}
var durationText by var durationText by
remember(key) { remember(key) {
mutableStateOf( mutableStateOf(
@ -640,6 +668,14 @@ private fun SessionExerciseForm(
) )
} }
var equipmentRevision by remember(key) { mutableStateOf(0) }
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
var equipmentSearch by remember(key) { mutableStateOf("") }
var customEquipmentName by remember(key) { mutableStateOf("") }
var selectedEquipmentId by remember(key) {
mutableStateOf(initialForm.selectedEquipmentId)
}
var error by var error by
remember(key) { remember(key) {
mutableStateOf< mutableStateOf<
@ -659,6 +695,58 @@ private fun SessionExerciseForm(
color = colors.accent, color = colors.accent,
) )
TrainlogInputField(
label = "Machine / équipement (optionnel)",
value = equipmentSearch,
onValueChange = { equipmentSearch = it },
)
TrainlogInputField(
label = "Nouvelle machine",
value = customEquipmentName,
onValueChange = { customEquipmentName = it },
)
TrainlogAction(
label = "Créer la machine",
description = "L'ajouter à votre catalogue puis la sélectionner pour cette entrée.",
accent = colors.success,
onClick = {
when (val result = repository.createCustomEquipment(customEquipmentName)) {
is CreateEquipmentResult.Created -> {
customEquipmentName = ""
equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText))
}
CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide."
CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà."
is CreateEquipmentResult.DatabaseError -> error = "Machine non créée : ${result.message}"
}
},
)
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
if (selectedEquipment != null) {
TrainlogAction(
label = "${selectedEquipment.displayName}",
description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." },
accent = colors.success,
onClick = {
selectedEquipmentId = null
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, null, weightText))
},
)
}
repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = {
selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText))
},
)
}
if ( if (
exercise.recordingMode == exercise.recordingMode ==
RecordingMode.SETS RecordingMode.SETS
@ -683,6 +771,8 @@ private fun SessionExerciseForm(
durationText, durationText,
speedText, speedText,
distanceText, distanceText,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -694,6 +784,29 @@ private fun SessionExerciseForm(
color = color =
colors.muted, colors.muted,
) )
SessionNumberField(
label = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
"Assistance (kg)"
} else {
"Charge (kg)"
},
value = weightText,
onValueChange = {
weightText = it
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, it,
)
)
},
)
TrainlogInfo(
text = "Une valeur par série séparée par ; (ex. 12,5;15). Une seule valeur s'applique à toutes les séries.",
color = colors.muted,
)
} else { } else {
SessionNumberField( SessionNumberField(
label = label =
@ -711,6 +824,8 @@ private fun SessionExerciseForm(
durationText, durationText,
speedText, speedText,
distanceText, distanceText,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -732,6 +847,8 @@ private fun SessionExerciseForm(
it, it,
speedText, speedText,
distanceText, distanceText,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -754,6 +871,8 @@ private fun SessionExerciseForm(
it, it,
speedText, speedText,
distanceText, distanceText,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -780,6 +899,8 @@ private fun SessionExerciseForm(
durationText, durationText,
it, it,
distanceText, distanceText,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -807,6 +928,8 @@ private fun SessionExerciseForm(
durationText, durationText,
speedText, speedText,
it, it,
selectedEquipmentId,
weightText,
) )
) )
}, },
@ -836,6 +959,9 @@ private fun SessionExerciseForm(
speedText, speedText,
distanceText = distanceText =
distanceText, distanceText,
equipmentId = selectedEquipmentId,
weightText = weightText,
entryId = initialForm.editingEntryId,
) )
if (draft == null) { if (draft == null) {
@ -877,11 +1003,15 @@ private fun currentForm(
durationText: String, durationText: String,
speedText: String, speedText: String,
distanceText: String, distanceText: String,
equipmentId: String? = null,
weightText: String = "",
): SessionDraftForm = ): SessionDraftForm =
SessionDraftForm( SessionDraftForm(
selectedExercise = exercise, selectedExercise = exercise,
selectedEquipmentId = equipmentId,
setCountText = setCountText, setCountText = setCountText,
repsText = repsText, repsText = repsText,
weightText = weightText,
durationText = durationText, durationText = durationText,
speedText = speedText, speedText = speedText,
distanceText = distanceText, distanceText = distanceText,
@ -1062,6 +1192,9 @@ private fun buildSessionExerciseDraft(
durationText: String, durationText: String,
speedText: String, speedText: String,
distanceText: String, distanceText: String,
equipmentId: String? = null,
weightText: String = "",
entryId: String? = null,
): SessionExerciseDraft? { ): SessionExerciseDraft? {
return if ( return if (
exercise.recordingMode == exercise.recordingMode ==
@ -1122,7 +1255,9 @@ private fun buildSessionExerciseDraft(
null null
} else { } else {
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
exercise = exercise, exercise = exercise,
equipmentId = equipmentId,
continuousDurationSeconds = continuousDurationSeconds =
minutes * 60, minutes * 60,
speedKmh = speed, speedKmh = speed,
@ -1139,12 +1274,17 @@ private fun buildSessionExerciseDraft(
repsText repsText
) ?: return null ) ?: return null
val weights = parseWeightSequence(weightText, reps.size) ?: return null
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
exercise = exercise, exercise = exercise,
equipmentId = equipmentId,
sets = sets =
reps.map { reps.mapIndexed { index, rep ->
SessionSetDraft( SessionSetDraft(
reps = it reps = rep,
weightKg = weights[index],
) )
}, },
) )
@ -1166,7 +1306,9 @@ private fun buildSessionExerciseDraft(
null null
} else { } else {
SessionExerciseDraft( SessionExerciseDraft(
entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(),
exercise = exercise, exercise = exercise,
equipmentId = equipmentId,
sets = sets =
List(count) { List(count) {
SessionSetDraft( SessionSetDraft(
@ -1179,6 +1321,50 @@ private fun buildSessionExerciseDraft(
} }
} }
/** Reconstruct editable text from the entry itself; editing never mutates a
* different entry or the global exercise definition. */
private fun formForExistingExercise(
draft: SessionExerciseDraft,
index: Int,
): SessionDraftForm =
SessionDraftForm(
selectedExercise = draft.exercise,
editingExerciseIndex = index,
editingEntryId = draft.entryId,
selectedEquipmentId = draft.equipmentId,
setCountText = draft.sets.size.toString(),
repsText = if (draft.exercise.trackingMode == TrackingMode.REPS) {
draft.sets.joinToString(",") { it.reps.toString() }
} else {
"3x10"
},
weightText = draft.sets.mapNotNull { it.weightKg }.joinToString(";") { "%g".format(java.util.Locale.FRANCE, it) },
durationText = if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
(draft.continuousDurationSeconds / 60).toString()
} else {
draft.sets.firstOrNull()?.durationSeconds?.toString() ?: "30"
},
speedText = draft.speedKmh?.toString().orEmpty(),
distanceText = draft.distanceKm?.toString().orEmpty(),
)
/** Accept French decimal commas without confusing them with the set separator.
* CONTRACT: blank means no load recorded; zero is a real explicit value. */
private fun parseWeightSequence(text: String, count: Int): List<Double?>? {
if (text.trim().isEmpty()) return List(count) { null }
val values = text.split(';').map { token ->
token.trim().replace(',', '.').toDoubleOrNull()
}
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
@Suppress("UNCHECKED_CAST")
val parsed = values as List<Double>
return when {
parsed.size == 1 -> List(count) { parsed.single() }
parsed.size == count -> parsed
else -> null
}
}
private fun draftSummary( private fun draftSummary(
draft: SessionExerciseDraft, draft: SessionExerciseDraft,
): String { ): String {

View file

@ -0,0 +1,38 @@
package com.labfytools.trainlog.data
import androidx.test.core.app.ApplicationProvider
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class EquipmentCatalogTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
@Test
fun canonicalManifestHasStableCompleteSearchableEntries() {
val entries = EquipmentCatalog.load(context)
assertEquals(38, entries.size)
assertEquals(entries.size, entries.map { it.equipmentId }.toSet().size)
assertTrue(entries.all { it.equipmentId.isNotBlank() && it.displayName.isNotBlank() })
assertTrue(entries.all { entry -> entry.aliases.all { it.isNotBlank() } })
assertTrue(entries.all { it.type.isNotBlank() })
assertTrue(EquipmentCatalog.search(entries, "LEG PRESS").any { it.equipmentId == "leg_press" })
assertTrue(EquipmentCatalog.search(entries, "Presse à pectoraux").any { it.equipmentId == "vertical_chest_press" })
assertTrue(EquipmentCatalog.search(entries, "presse à cuisses").any { it.equipmentId == "leg_press" })
assertTrue(EquipmentCatalog.search(entries, "ischio").map { it.equipmentId }.containsAll(listOf("seated_leg_curl", "prone_leg_curl")))
}
@Test
fun assistedMachineIsOnePhysicalEquipmentWithAssistanceSemantics() {
val entries = EquipmentCatalog.load(context)
assertEquals(EquipmentLoadSemantics.ASSISTANCE, entries.single { it.equipmentId == "assisted_dip_chin_machine" }.loadSemantics)
val relations = EquipmentCatalog.exerciseEquipmentRelations(context)
assertEquals(setOf("assisted_chin", "assisted_dip"), relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.map { it.exerciseId }.toSet())
assertTrue(relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.all { it.loadSemantics == EquipmentLoadSemantics.ASSISTANCE })
}
}

View file

@ -63,6 +63,7 @@ class TrainlogRepositoryDraftTest {
exercises = listOf( exercises = listOf(
SessionExerciseDraft( SessionExerciseDraft(
exercise = reps, exercise = reps,
equipmentId = "leg_press",
sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) }, sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) },
), ),
SessionExerciseDraft( SessionExerciseDraft(
@ -79,6 +80,7 @@ class TrainlogRepositoryDraftTest {
sessionType = SessionType.MAX_TEST, sessionType = SessionType.MAX_TEST,
form = SessionDraftForm( form = SessionDraftForm(
selectedExercise = reps, selectedExercise = reps,
selectedEquipmentId = "treadmill",
setCountText = "4", setCountText = "4",
repsText = "4,5,6,", repsText = "4,5,6,",
durationText = "31", durationText = "31",
@ -161,6 +163,68 @@ class TrainlogRepositoryDraftTest {
assertEquals(1, repo.listSessions().size) assertEquals(1, repo.listSessions().size)
} }
@Test
fun repeatedContinuousExercisePersistsDistinctOccurrencesAcrossReopenAndFinalize() {
val repo = openRepository()
val marche = createExercise(repo, "Marche", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
val first = SessionExerciseDraft(exercise = marche, continuousDurationSeconds = 600)
val second = SessionExerciseDraft(exercise = marche, continuousDurationSeconds = 900)
val draft = ActiveSessionDraft(exercises = listOf(first, second))
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
repo.close(); repository = null
val reopened = openRepository()
val restored = loadDraft(reopened)
assertEquals(listOf(600, 900), restored.exercises.map { it.continuousDurationSeconds })
assertEquals(listOf(marche.exerciseId, marche.exerciseId), restored.exercises.map { it.exercise.exerciseId })
assertEquals(2, restored.exercises.map { it.entryId }.toSet().size)
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val sessionId = reopened.listSessions().single().sessionId
val detail = reopened.getSessionDetail(sessionId)!!
assertEquals(listOf(600, 900), detail.exercises.map { it.continuousDurationSeconds })
assertEquals(2, detail.exercises.map { it.entryId }.toSet().size)
assertTrue(
reopened.setCompletedSessionEquipment(
sessionId,
detail.exercises[0].entryId,
"treadmill",
),
)
assertTrue(
reopened.setCompletedSessionEquipment(
sessionId,
detail.exercises[1].entryId,
"leg_press",
),
)
val edited = reopened.getSessionDetail(sessionId)!!
assertTrue(edited.exercises[0].equipmentDisplayName != null)
assertTrue(edited.exercises[1].equipmentDisplayName != null)
}
@Test
fun weightedMachineSetsAndCustomEquipmentSurviveDraftFinalizeAndReopen() {
val repo = openRepository()
val exercise = createExercise(repo, "Presse", RecordingMode.SETS, TrackingMode.REPS)
val custom = repo.createCustomEquipment("Presse personnelle")
assertTrue(custom is CreateEquipmentResult.Created)
val equipmentId = (custom as CreateEquipmentResult.Created).equipment.equipmentId
val draft = ActiveSessionDraft(
exercises = listOf(SessionExerciseDraft(
exercise = exercise, equipmentId = equipmentId,
sets = listOf(SessionSetDraft(10, weightKg = 12.5), SessionSetDraft(8, weightKg = 15.0)),
)),
form = SessionDraftForm(selectedExercise = exercise, selectedEquipmentId = equipmentId, weightText = "12,5;15"),
)
assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft))
repo.close(); repository = null
val reopened = openRepository()
assertEquals(draft.exercises, loadDraft(reopened).exercises)
assertTrue(reopened.listEquipment().any { it.equipmentId == equipmentId })
assertTrue(reopened.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val detail = reopened.getSessionDetail(reopened.listSessions().single().sessionId)!!
assertEquals(listOf(12.5, 15.0), detail.exercises.single().sets.map { it.weightKg })
}
@Test @Test
fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() { fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() {
val repo = openRepository() val repo = openRepository()
@ -452,14 +516,55 @@ class TrainlogRepositoryDraftTest {
} }
@Test @Test
fun versionThreeMigrationPreservesCompletedAndBodyData() { fun completedSessionKeepsEquipmentAcrossReopenAndCompanionExport() {
createVersionThreeFixture(context.getDatabasePath(databaseName).path) val first = openRepository()
val exercise = createExercise(first, "Presse test", RecordingMode.SETS, TrackingMode.REPS)
val saved = first.saveSession(
SessionDraft(
exercises = listOf(
SessionExerciseDraft(
exercise = exercise,
equipmentId = "leg_press",
sets = listOf(SessionSetDraft(reps = 10)),
),
),
),
)
assertTrue(saved is SaveSessionResult.Saved)
val sessionId = (saved as SaveSessionResult.Saved).sessionId
val exported = JSONObject(first.buildEquipmentAssociationsJson()).getJSONArray("associations")
assertEquals("set", exported.getJSONObject(0).getString("state"))
assertEquals("leg_press", exported.getJSONObject(0).getString("equipment_id"))
first.close()
repository = null
val reopened = openRepository()
val again = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations")
assertEquals(sessionId, again.getJSONObject(0).getString("session_id"))
assertEquals("leg_press", again.getJSONObject(0).getString("equipment_id"))
val cleared = JSONObject()
.put("format", "trainlog-equipment-associations")
.put("version", 1)
.put("generated_at", "2026-01-01T00:00:00+00:00")
.put("associations", org.json.JSONArray().put(JSONObject()
.put("session_id", sessionId)
.put("exercise_id", exercise.exerciseId)
.put("state", "cleared")))
assertTrue(reopened.applyPcEquipmentAssociationsJson(cleared.toString()) is EquipmentAssociationImportResult.Applied)
val afterClear = JSONObject(reopened.buildEquipmentAssociationsJson()).getJSONArray("associations")
assertEquals("cleared", afterClear.getJSONObject(0).getString("state"))
}
@Test
fun versionFourMigrationPreservesCompletedBodyAndDurableDraftData() {
createVersionFourFixture(context.getDatabasePath(databaseName).path)
val repo = openRepository() val repo = openRepository()
assertEquals(1, repo.listExercises().size) assertEquals(1, repo.listExercises().size)
assertEquals(1, repo.listSessions().size) assertEquals(1, repo.listSessions().size)
assertEquals(1, repo.listBodyObservations().size) assertEquals(1, repo.listBodyObservations().size)
assertEquals(ActiveDraftLoadResult.None, repo.loadActiveSessionDraft()) val loadedDraft = loadDraft(repo)
assertEquals("Fixture", loadedDraft.form.selectedExercise?.name)
assertEquals(1, loadedDraft.exercises.size)
SQLiteDatabase.openDatabase( SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path, context.getDatabasePath(databaseName).path,
null, null,
@ -467,7 +572,11 @@ 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(4, cursor.getInt(0)) assertEquals(7, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertTrue(cursor.isNull(0))
} }
db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor -> db.rawQuery("PRAGMA foreign_key_check;", null).use { cursor ->
assertFalse(cursor.moveToFirst()) assertFalse(cursor.moveToFirst())
@ -499,7 +608,8 @@ class TrainlogRepositoryDraftTest {
return (result as CreateExerciseResult.Created).exercise return (result as CreateExerciseResult.Created).exercise
} }
private fun createVersionThreeFixture(path: String) { /** A real v4 shape: completed history plus the v4 durable draft tables. */
private fun createVersionFourFixture(path: String) {
SQLiteDatabase.openOrCreateDatabase(path, null).use { db -> SQLiteDatabase.openOrCreateDatabase(path, null).use { db ->
db.execSQL( db.execSQL(
"CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, " + "CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT NOT NULL UNIQUE, " +
@ -547,7 +657,33 @@ class TrainlogRepositoryDraftTest {
"INSERT INTO body_observations(id, observation_id, observed_at, body_weight_kg) " + "INSERT INTO body_observations(id, observation_id, observed_at, body_weight_kg) " +
"VALUES(1, 'bo_fixture', '2026-01-02T03:04:05+01:00', 70.5);" "VALUES(1, 'bo_fixture', '2026-01-02T03:04:05+01:00', 70.5);"
) )
db.execSQL("PRAGMA user_version = 3;") db.execSQL(
"CREATE TABLE active_session_draft(id INTEGER PRIMARY KEY CHECK(id = 1), " +
"session_type TEXT NOT NULL, selected_exercise_row_id INTEGER REFERENCES exercises(id) ON DELETE SET NULL, " +
"selected_exercise_label TEXT, set_count_text TEXT NOT NULL, reps_text TEXT NOT NULL, " +
"duration_text TEXT NOT NULL, speed_text TEXT NOT NULL, distance_text TEXT NOT NULL, updated_at TEXT NOT NULL);"
)
db.execSQL(
"CREATE TABLE draft_session_exercises(id INTEGER PRIMARY KEY, draft_id INTEGER NOT NULL " +
"REFERENCES active_session_draft(id) ON DELETE CASCADE, exercise_row_id INTEGER NOT NULL " +
"REFERENCES exercises(id) ON DELETE RESTRICT, position INTEGER NOT NULL, recording_mode TEXT NOT NULL, " +
"tracking_mode TEXT NOT NULL, data_fields INTEGER NOT NULL, UNIQUE(draft_id, position), UNIQUE(draft_id, exercise_row_id));"
)
db.execSQL(
"CREATE TABLE draft_performed_sets(id INTEGER PRIMARY KEY, draft_exercise_row_id INTEGER NOT NULL " +
"REFERENCES draft_session_exercises(id) ON DELETE CASCADE, position INTEGER NOT NULL, reps INTEGER, " +
"duration_seconds INTEGER, UNIQUE(draft_exercise_row_id, position));"
)
db.execSQL(
"CREATE TABLE draft_continuous_activity(id INTEGER PRIMARY KEY, draft_exercise_row_id INTEGER NOT NULL UNIQUE " +
"REFERENCES draft_session_exercises(id) ON DELETE CASCADE, duration_seconds INTEGER NOT NULL, speed_kmh REAL, distance_km REAL);"
)
db.execSQL(
"INSERT INTO active_session_draft VALUES(1, 'training', 1, 'Fixture', '3', '3x10', '', '', '', '2026-01-02T03:04:05+01:00');"
)
db.execSQL("INSERT INTO draft_session_exercises VALUES(1, 1, 1, 0, 'sets', 'reps', 0);")
db.execSQL("INSERT INTO draft_performed_sets VALUES(1, 1, 0, 10, NULL);")
db.execSQL("PRAGMA user_version = 4;")
} }
} }
} }

48
catalog/equipment-v1.json Normal file
View file

@ -0,0 +1,48 @@
{
"format": "trainlog-equipment-catalog",
"version": 1,
"equipment": [
{"id":"diverging_lat_pulldown","label_name":"DIVERGING LAT PULLDOWN","display_name":"Diverging Lat Pulldown — Tirage vertical divergent","aliases":["tirage vertical divergent","lat pulldown divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"lat_pull","label_name":"LAT PULL","display_name":"Lat Pull — Tirage vertical","aliases":["tirage vertical","lat pull","dos"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"lat_pulldown","label_name":"LAT PULLDOWN","display_name":"Lat Pulldown — Tirage vertical","aliases":["tirage vertical","tirage poitrine","lat pulldown"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"seated_row","label_name":"SEATED ROW","display_name":"Seated Row — Tirage horizontal assis","aliases":["rowing assis","tirage horizontal","dos"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"diverging_seated_row","label_name":"DIVERGING SEATED ROW","display_name":"Diverging Seated Row — Tirage horizontal divergent","aliases":["rowing divergent","tirage horizontal divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"low_row","label_name":"LOW ROW","display_name":"Low Row — Tirage horizontal bas","aliases":["rowing bas","tirage bas","dos"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"vertical_chest_press","label_name":"VERTICAL CHEST PRESS","display_name":"Vertical Chest Press — Presse à pectoraux","aliases":["chest press","presse poitrine","développé machine","pectoraux"],"type":"machine","load_semantics":"external"},
{"id":"converging_shoulder_press","label_name":"CONVERGING SHOULDER PRESS","display_name":"Converging Shoulder Press — Presse épaules convergente","aliases":["shoulder press","développé épaules","presse épaules","épaules"],"type":"machine","load_semantics":"external"},
{"id":"rear_delt_pec_fly","label_name":"REAR DELT / PEC FLY","display_name":"Rear Delt / Pec Fly — Oiseaux / Pec Fly","aliases":["pec fly","butterfly","pec deck","rear delt","reverse fly","oiseaux","deltoïdes postérieurs","pectoraux"],"type":"selectorized_combo_machine","load_semantics":"external"},
{"id":"leg_press","label_name":"LEG PRESS","display_name":"Leg Press — Presse à cuisses","aliases":["presse à cuisses","presse jambes","jambes","leg press"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"plate_loaded_leg_press","label_name":"","display_name":"Leg Press à disques — Presse à cuisses","aliases":["presse à disques","plate loaded leg press","leg press"],"type":"plate_loaded_machine","load_semantics":"external"},
{"id":"perfect_squat","label_name":"PERFECT SQUAT","display_name":"Perfect Squat — Squat guidé","aliases":["squat machine","squat guidé","jambes","perfect squat"],"type":"plate_loaded_machine","load_semantics":"external"},
{"id":"leg_extension","label_name":"LEG EXTENSION","display_name":"Leg Extension — Extension des jambes","aliases":["extension quadriceps","quadriceps","leg extension"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"seated_leg_curl","label_name":"SEATED LEG CURL","display_name":"Seated Leg Curl — Leg curl assis","aliases":["leg curl assis","ischio","ischio-jambiers","flexion jambes"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"prone_leg_curl","label_name":"PRONE LEG CURL","display_name":"Prone Leg Curl — Leg curl allongé","aliases":["leg curl couché","leg curl allongé","ischio","ischio-jambiers"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"hip_abduction","label_name":"HIP ABDUCTION","display_name":"Hip Abduction — Abducteurs","aliases":["abducteurs","ouverture jambes","fessiers","hip abduction"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"hip_adduction","label_name":"HIP ADDUCTION","display_name":"Hip Adduction — Adducteurs","aliases":["adducteurs","fermeture jambes","hip adduction"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"abdominal","label_name":"ABDOMINAL","display_name":"Abdominal — Crunch machine","aliases":["abdos","crunch","abdominal machine"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"back_extension","label_name":"BACK EXTENSION","display_name":"Back Extension — Extension lombaires","aliases":["lombaires","extension dos","bas du dos","back extension"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"rotary_torso","label_name":"ROTARY TORSO","display_name":"Rotary Torso — Rotation du buste","aliases":["obliques","rotation tronc","rotation buste","rotary torso"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"abdominal_bench","label_name":"","display_name":"Banc à abdominaux incliné","aliases":["banc abdos","crunch bench","sit-up bench"],"type":"bench","load_semantics":"bodyweight"},
{"id":"arm_curl","label_name":"ARM CURL","display_name":"Arm Curl — Curl biceps","aliases":["biceps","curl","curl machine"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"seated_dip","label_name":"SEATED DIP","display_name":"Seated Dip — Dips assis / Triceps","aliases":["triceps","dips assis","seated dip"],"type":"selectorized_machine","load_semantics":"external"},
{"id":"assisted_dip_chin_machine","label_name":"","display_name":"Machine Chin / Dip Assist","aliases":["chin assist","dip assist","traction assistée","dips assistés"],"type":"assisted_bodyweight_machine","load_semantics":"assistance"},
{"id":"functional_trainer","label_name":"","display_name":"Poulie réglable / Functional Trainer","aliases":["poulie","câble","cable machine","crossover","functional trainer"],"type":"cable_machine","load_semantics":"external"},
{"id":"dual_adjustable_pulley","label_name":"","display_name":"Double poulie réglable","aliases":["double poulie","crossover","cable crossover"],"type":"cable_machine","load_semantics":"external"},
{"id":"dumbbells","label_name":"","display_name":"Haltères","aliases":[],"type":"free_weight","load_semantics":"external"},
{"id":"kettlebells","label_name":"","display_name":"Kettlebells","aliases":[],"type":"free_weight","load_semantics":"external"},
{"id":"weighted_bag","label_name":"","display_name":"Sac lesté","aliases":["sandbag","power bag"],"type":"free_weight","load_semantics":"external"},
{"id":"medicine_ball","label_name":"","display_name":"Medicine Ball","aliases":["med ball","ballon lesté"],"type":"functional_equipment","load_semantics":"external"},
{"id":"suspension_trainer","label_name":"","display_name":"Sangles de suspension","aliases":["TRX","suspension trainer"],"type":"functional_equipment","load_semantics":"bodyweight"},
{"id":"battle_ropes","label_name":"","display_name":"Battle Ropes — Cordes ondulatoires","aliases":["corde","battle rope","cordes"],"type":"functional_equipment","load_semantics":"bodyweight"},
{"id":"treadmill","label_name":"","display_name":"Tapis de course","aliases":["treadmill","tapis"],"type":"cardio_machine","load_semantics":"cardio"},
{"id":"upright_bike","label_name":"","display_name":"Vélo droit","aliases":["exercise bike","vélo cardio"],"type":"cardio_machine","load_semantics":"cardio"},
{"id":"recumbent_bike","label_name":"","display_name":"Vélo semi-allongé","aliases":["vélo couché","recumbent bike"],"type":"cardio_machine","load_semantics":"cardio"},
{"id":"indoor_cycle","label_name":"","display_name":"Vélo indoor / Spinning","aliases":["spinning","body bike","indoor bike"],"type":"cardio_machine","load_semantics":"cardio"},
{"id":"rowing_machine","label_name":"","display_name":"Rameur","aliases":["rowing ergometer","rower"],"type":"cardio_machine","load_semantics":"cardio"},
{"id":"stair_climber","label_name":"","display_name":"Escalier / Stair Climber","aliases":["stairmaster","climber","escalier","stepmill"],"type":"cardio_machine","load_semantics":"cardio"}
],
"exercise_equipment": [
{"exercise_id":"assisted_chin","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"},
{"exercise_id":"assisted_dip","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"}
]
}

View file

@ -8,6 +8,14 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
The desktop remains the canonical long-term history and analytics store. The desktop remains the canonical long-term history and analytics store.
## Session exchange V2
Completed session occurrences persist an `entry_id`; it is never regenerated
for exchange. Android publishes `trainlog-mobile-export-v2.json` as the active
desktop snapshot and imports `trainlog-pc-mobile-export-v2.json` after the PC
catalogue. The artifact preserves occurrence order, continuous metrics, set
weights and equipment. The legacy V1 contract remains separate and readable.
## 2. Implemented navigation ## 2. Implemented navigation
```text ```text
@ -25,7 +33,7 @@ Accueil
Android local database version: Android local database version:
```text ```text
4 7
``` ```
Domain tables cover: Domain tables cover:
@ -41,9 +49,11 @@ body_observations
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 adds `active_session_draft`, `draft_session_exercises`, Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
`draft_performed_sets` and `draft_continuous_activity`. The additive v3 -> v4 `draft_performed_sets` and `draft_continuous_activity`. The implemented
migration preserves catalog, completed sessions/actuals and body observations. additive v4 -> v7 chain preserves catalog, completed sessions/actuals, body
observations and the draft while adding the shared equipment catalogue,
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.
## 4. Exercise catalog ## 4. Exercise catalog
@ -120,6 +130,21 @@ speed or distance.
Continuous work does not create fake sets. Continuous work does not create fake sets.
### Occurrences, equipment and actual loads
The same catalogue `exercise_id` may be added more than once to a session.
Every occurrence receives its own stable `entry_id`, position, actual sets and
optional equipment selection. Editing one occurrence replaces only that entry;
it does not merge or alter another passage of the same exercise.
`Machine / équipement (optionnel)` searches the shared manifest by display
name, physical-machine label and aliases. A selected equipment identity is
stored on that occurrence in both the active draft and completed session.
For `SETS + REPS`, `Charge (kg)` accepts one value for all sets or `;`-separated
per-set values; French decimal commas are accepted. `Assistance (kg)` is an
explicit alternative load semantic, not an external charge. Empty load and an
entered zero remain distinct.
## 6. Session draft editing ## 6. Session draft editing
The repository durably saves every meaningful mutation, including session type, The repository durably saves every meaningful mutation, including session type,
@ -196,11 +221,15 @@ bo_<uuid-v4>
Android maintains: Android maintains:
```text ```text
Download/Trainlog/trainlog-mobile-export-v1.json Download/Trainlog/trainlog-mobile-export-v2.json
``` ```
The snapshot is refreshed after relevant local changes, including exercise, The V2 snapshot is refreshed after relevant local changes, including exercise,
session, body-observation, and PC-catalog updates. session, body-observation, equipment association and PC-catalog updates. It
preserves `entry_id`, occurrence position, optional equipment and actual
per-set weights. Android also publishes the V2 companion
`trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are
targeted by `(session_id, entry_id)`.
The user does not need a separate manual export step before synchronization. The user does not need a separate manual export step before synchronization.
@ -306,6 +335,31 @@ Android is not intended to own:
## 15. Test-max sessions ## 15. Test-max sessions
## 16. Equipment and multi-occurrence exchange V2
During exercise entry, `Machine / équipement (optionnel)` searches the shared
catalogue by display name, physical-machine label and aliases. The selected
canonical ID belongs to that session exercise entry, is durable in the active
draft and completed session, and is visible in session detail. It may be
cleared. The active V2 exchange preserves multiple ordered occurrences of the
same exercise in one session through `entry_id`. The frozen V1 artifacts remain
readable only as legacy artifacts and keep their historical one-exercise
identity assumptions; V1 is not rewritten to claim V2 support.
For a `SETS + REPS` exercise, selecting equipment never changes that exercise
profile: the form retains per-set repetitions and exposes `Charge (kg)`. French
decimal input is accepted (`12,5`); one value applies to all sets or values may
be separated with `;`. Assisted equipment is explicitly labelled
`Assistance (kg)`. Empty load remains distinct from an entered zero.
`Nouvelle machine` in that same selector creates a persistent local custom
equipment entry with a generated stable `eq_…` ID and selects it immediately.
The shared bundled catalogue is synchronized by canonical IDs; a custom ID is
not silently converted to null on the PC and is rejected until its definition
is available to the receiving catalogue.
The active-session list exposes `Modifier <exercice>`; saving replaces that
entry in place, while cancelling only discards the form and preserves it.
Android session entry exposes: Android session entry exposes:
```text ```text

View file

@ -120,7 +120,9 @@ Continuous work is persisted separately from performed sets.
### Desktop ### Desktop
Desktop SQLite schema v5 is canonical long-term history. Desktop SQLite schema v7 is canonical long-term history. `session_exercises`
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
occur more than once in one session without identity fusion.
Main tables: Main tables:
@ -135,7 +137,7 @@ body_observations
### Android ### Android
Android has an independent local SQLite schema, currently v4. Android has an independent local SQLite schema, currently v7.
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.

View file

@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V7=PASS
ANDROID_LOCAL_DATABASE_V4=PASS ANDROID_LOCAL_DATABASE_V7=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
@ -46,8 +46,10 @@ ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
DESKTOP_TESTS=22/22 PASS DESKTOP_TESTS=25/25 PASS
ANDROID_BUILD=PASS ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=PASS HARDWARE_SYNC_VALIDATION=PASS
``` ```
@ -57,13 +59,16 @@ 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 v5; - SQLite schema v7, with stable ordered `session_exercises.entry_id` and
occurrence-level equipment identity;
- direct session entry; - direct session entry;
- persisted session detail and editing; - persisted session detail and editing;
- exercise removal from a session through transactional child replacement; - exercise removal from a session through transactional child replacement;
- exercise catalog; - exercise catalog;
- profile-aware set and continuous activities; - profile-aware set and continuous activities;
- heterogeneous repetition sets; - heterogeneous repetition sets;
- multiple occurrences of one catalogue exercise in a session;
- per-set actual loads with distinct external/assistance semantics;
- body-observation creation/history/editing; - body-observation creation/history/editing;
- body graphs and normalized overlays; - body graphs and normalized overlays;
- exercise performance history; - exercise performance history;
@ -87,7 +92,7 @@ Primary navigation:
Implemented: Implemented:
- native Kotlin/Compose application; - native Kotlin/Compose application;
- local SQLite database v4, with non-destructive v3 -> v4 migration; - local SQLite database v7, with non-destructive v3 -> v7 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;
@ -97,6 +102,8 @@ Implemented:
- heterogeneous repetition-set entry; - heterogeneous repetition-set entry;
- exercise removal from the current session draft; - exercise removal from the current session draft;
- continuous activity recording; - continuous activity recording;
- shared equipment selection, local custom equipment creation and occurrence
equipment persistence;
- local session history/detail; - local session history/detail;
- body measurements; - body measurements;
- automatic mobile snapshot maintenance; - automatic mobile snapshot maintenance;
@ -127,10 +134,13 @@ Artifacts:
```text ```text
Android -> PC Android -> PC
trainlog-mobile-export-v1.json trainlog-mobile-export-v2.json
trainlog-equipment-associations-v2.json
PC -> Android PC -> Android
trainlog-pc-catalog-v1.json trainlog-pc-catalog-v1.json
trainlog-pc-mobile-export-v2.json
trainlog-equipment-associations-v2.json
Android -> PC agent Android -> PC agent
trainlog-sync-request-v1.json trainlog-sync-request-v1.json
@ -141,6 +151,10 @@ PC agent -> Android
The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`. The desktop TUI and `trainlog-syncd` share `trainlog_sync_run()`.
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
multi-occurrence semantics retroactively.
No SQLite file is copied. No SQLite file is copied.
No mounted Android filesystem is required. No mounted Android filesystem is required.
@ -150,7 +164,7 @@ No mounted Android filesystem is required.
Desktop: Desktop:
```text ```text
22/22 Meson tests PASS 25/25 Meson tests PASS
frozen JSON validator PASS frozen JSON validator PASS
import-contract validator PASS import-contract validator PASS
git diff --check PASS git diff --check PASS
@ -159,8 +173,9 @@ git diff --check PASS
Android: Android:
```text ```text
assembleDebug PASS assembleDebug and Android unit tests are run for every Android delivery.
host repository tests 8/8 PASS The prior device baseline below is hardware evidence, not a claim that every
new implementation detail was re-exercised on the device in this document.
device instrumentation 5/5 PASS device instrumentation 5/5 PASS
real Samsung background/process-death/force-stop/resume matrix PASS real Samsung background/process-death/force-stop/resume matrix PASS
real migration and original user-data preservation PASS real migration and original user-data preservation PASS
@ -191,7 +206,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS
WORKING_LOAD_PERCENTAGES=PASS WORKING_LOAD_PERCENTAGES=PASS
ASSISTANCE_DIRECTION_AWARE=PASS ASSISTANCE_DIRECTION_AWARE=PASS
ANDROID_MAX_TEST_SESSION=PASS ANDROID_MAX_TEST_SESSION=PASS
DESKTOP_TESTS=22/22 PASS DESKTOP_TESTS=25/25 PASS
``` ```
A measured maximum is derived only from explicit `max_test` sessions. Ordinary A measured maximum is derived only from explicit `max_test` sessions. Ordinary
@ -212,7 +227,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=22/22 PASS DESKTOP_TESTS=25/25 PASS
``` ```
Android remains capture-only for this feature. Android remains capture-only for this feature.

View file

@ -3,8 +3,8 @@
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=5 TRAINLOG_DATABASE_SCHEMA_VERSION=7
DATABASE_SCHEMA_V5=PASS DATABASE_SCHEMA_V7=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -23,13 +23,19 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
5 7
``` ```
Supported historical databases are migrated explicitly through the implemented Supported historical databases are migrated explicitly through the implemented
migration chain. A database newer than the running binary understands is migration chain. A database newer than the running binary understands is
rejected. rejected.
Version 7 assigns `session_exercises.entry_id` to each stable occurrence.
`exercise_id` remains only the catalogue identity and may occur more than once
in a session. The v6 → v7 migration rebuilds the obsolete uniqueness
constraint while retaining rows, sets, continuous activities, weights and
equipment associations.
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.
@ -83,6 +89,7 @@ Ordered exercise occurrence inside one session.
```text ```text
session_row_id session_row_id
exercise_row_id exercise_row_id
entry_id UNIQUE stable occurrence identity
recording_mode recording_mode
data_fields data_fields
position position
@ -92,6 +99,7 @@ target_sets
target_reps target_reps
target_duration_seconds target_duration_seconds
target_weight_kg target_weight_kg
equipment_id nullable canonical equipment identity
notes notes
``` ```
@ -296,11 +304,11 @@ Migration-specific regression coverage includes:
schema_v5_migration schema_v5_migration
``` ```
The current normal desktop suite contains 22 tests. The current normal desktop suite contains 25 tests.
## 11. Measured-max derivation ## 11. Measured-max derivation
Measured maxima require no desktop schema v6. Measured maxima require no schema change beyond the current desktop schema v7.
The existing `sessions.session_type = max_test` classification plus actual The existing `sessions.session_type = max_test` classification plus actual
`performed_sets` are sufficient. `performed_sets` are sufficient.
@ -335,7 +343,21 @@ No extra maximum row is persisted; results are derived from canonical history.
## 12. Body analytics persistence rule ## 12. Body analytics persistence rule
Body analytics require no schema v6. ## 13. Equipment and occurrence migration
Desktop schema v6 added nullable `session_exercises.equipment_id`, which stores
a canonical manifest ID rather than a local SQLite row ID. Schema v7 adds the
non-null stable `entry_id` and removes the obsolete
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
preserves primary keys, completed sessions, ordered sets, continuous activities,
per-set weights and equipment values.
Android schema v6 added nullable `weight_kg` to completed and durable draft
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
occurrences. Actual per-set weights remain independent values, so heterogeneous
sets and weights survive edit, finalization, reopen and V2 exchange.
Body analytics require no schema change beyond schema v7.
Canonical persistence continues to contain only measurements actually entered Canonical persistence continues to contain only measurements actually entered
by the user. by the user.

View file

@ -14,8 +14,8 @@ GATE_1=PASS
GATE_2=PASS GATE_2=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
DESKTOP_SCHEMA_V5=PASS DESKTOP_SCHEMA_V7=PASS
ANDROID_LOCAL_DATABASE_V4=PASS ANDROID_LOCAL_DATABASE_V7=PASS
DIRECT_MTP_TRANSPORT=PASS DIRECT_MTP_TRANSPORT=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS
EXERCISE_EDIT_V1=PASS EXERCISE_EDIT_V1=PASS
ANDROID_BANNER_PARITY_V1=PASS ANDROID_BANNER_PARITY_V1=PASS
DESKTOP_TESTS=22/22 PASS DESKTOP_TESTS=25/25 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

View file

@ -12,6 +12,8 @@ ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS ANDROID_SYNC_RECEIPT=PASS
TUI_SYNC_LOG_SHOW=PASS TUI_SYNC_LOG_SHOW=PASS
BIDIRECTIONAL_SYNC_V1=PASS BIDIRECTIONAL_SYNC_V1=PASS
MULTI_OCCURRENCE_SESSION_V2=PASS
EQUIPMENT_ASSOCIATIONS_V2=PASS
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
``` ```
@ -37,12 +39,23 @@ Framework folder grant.
| Direction | File | Format | | Direction | File | Format |
| --- | --- | --- | | --- | --- | --- |
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 | | Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) |
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| 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-mobile-export-v2.json` | `trainlog-mobile-export` v2 |
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
| 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 |
No SQLite file is transferred. No SQLite file is transferred.
V2 is a separate format: every session entry has an `entry_id`, `position`,
metrics, actual loads and optional equipment identity. This permits two
occurrences of the same exercise without fusion. V1 remains readable with its
frozen contract. A V1 historical session is reconciled with V2 only when the
exercise/order correspondence is unambiguous; otherwise the importer reports
a conflict rather than silently overwriting data.
## 4. Android -> PC mobile snapshot ## 4. Android -> PC mobile snapshot
Header: Header:
@ -50,7 +63,7 @@ Header:
```json ```json
{ {
"format": "trainlog-mobile-export", "format": "trainlog-mobile-export",
"version": 1 "version": 2
} }
``` ```
@ -62,6 +75,20 @@ sessions
body_observations body_observations
``` ```
V2 session entries additionally carry:
```text
entry_id stable occurrence identity
position stable order within session
equipment_id optional canonical equipment identity
weight_kg optional actual value on each set
```
The desktop imports sessions first, preserving `entry_id`, then applies the
equipment companion only after all referenced entries exist. Reimporting either
artifact reconciles stable identities; it neither duplicates sessions nor
regenerates occurrence IDs.
Exercise profile fields: Exercise profile fields:
```text ```text
@ -234,7 +261,10 @@ One synchronization transaction performs:
```text ```text
mobile snapshot download mobile snapshot download
-> mobile import -> mobile import
-> equipment companion import by (session_id, entry_id)
-> PC catalog export -> PC catalog export
-> PC mobile V2 export
-> PC equipment companion V2 export
-> PC catalog MTP publication -> PC catalog MTP publication
-> optional receipt publication -> optional receipt publication
-> structured run history -> structured run history
@ -315,6 +345,22 @@ overloading frozen Trainlog JSON v1
## 14. Hardware validation ## 14. Hardware validation
## 15. Equipment associations V2 and legacy V1
`TRAINLOG_FORMAT_V1` remains frozen. The active companion is
`trainlog-equipment-associations-v2.json`, format
`trainlog-equipment-associations`, version `2`. Each row is identified by
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
for an intentional removal. A missing companion conveys no equipment
information and cannot clear a previously known choice. Unknown canonical IDs,
unknown entries and ambiguous identities reject the companion transaction
explicitly; an unknown equipment reference is never silently changed to null.
The historical V1 companion remains readable only where its
`(session_id, exercise_id)` targeting is unambiguous. It cannot represent two
occurrences of the same exercise in one session and is not redefined to do so.
Validated on the physical Android device: Validated on the physical Android device:
```text ```text

View file

@ -63,32 +63,35 @@ Current normal suite:
```text ```text
1 database 1 database
2 catalog 2 catalog
3 session_detail 3 equipment_catalog
4 duration 4 session_detail
5 body_metrics 5 duration
6 bodyviz 6 body_metrics
7 exercise_performance 7 bodyviz
8 session_type_schema 8 exercise_performance
9 session_edit 9 session_type_schema
10 body_observation_edit 10 session_edit
11 mtp 11 body_observation_edit
12 continuous_session 12 mtp
13 continuous_detail 13 continuous_session
14 reps 14 continuous_detail
15 exercise_profile_schema 15 exercise_profile_schema
16 usb 16 usb
17 variable_sets 17 reps
18 schema_v5_migration 18 variable_sets
19 measured_max 19 schema_v5_migration
20 body_analytics 20 measured_max
21 terminal_input_event_type_policy 21 body_analytics
22 mobile_import_variable_sets 22 terminal_input_event_type_policy
23 mobile_import_multi_occurrence
24 equipment_associations_exchange
25 mobile_import_variable_sets
``` ```
Validated checkpoint: Validated checkpoint:
```text ```text
22/22 PASS 25/25 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
@ -104,12 +107,17 @@ Notable regression coverage:
- profile-aware exercise constraints; - profile-aware exercise constraints;
- continuous activity without fake sets; - continuous activity without fake sets;
- repetition shorthand/list/pyramid parsing; - repetition shorthand/list/pyramid parsing;
- direct v4 -> v5 database migration; - direct v4 -> v7 database migration;
- heterogeneous mobile-set import; - heterogeneous mobile-set import;
- Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a
RELEASE event is consumed without creating a second navigation action. RELEASE event is consumed without creating a second navigation action.
- targetless mobile SETS persistence; - targetless mobile SETS persistence;
- mobile-import idempotence. - mobile-import idempotence.
- V2 mobile import with repeated exercise occurrences and stable `entry_id`;
- companion equipment import after session import, including idempotent
reimport and independent associations for repeated occurrences;
- rejection of the historical equipment-import invocation without its required
`--database` target, followed by the corrected complete V2 export chain.
- stable-ID mobile-to-desktop rename reconciliation without duplicate catalog - stable-ID mobile-to-desktop rename reconciliation without duplicate catalog
rows or historical-reference replacement. rows or historical-reference replacement.
@ -258,7 +266,7 @@ Coverage proves:
Current normal baseline: Current normal baseline:
```text ```text
22/22 PASS 25/25 PASS
``` ```
## 12. Body analytics regression ## 12. Body analytics regression
@ -283,16 +291,18 @@ Coverage includes:
Current normal baseline: Current normal baseline:
```text ```text
22/22 PASS 25/25 PASS
``` ```
## 13. Android session draft v1 ## 13. Android session draft v1
Android schema v4 adds one durable active draft with an explicit additive v3 -> Android schema v4 introduced one durable active draft; the current additive
v4 migration. The current host suite has **8 tests**, covering all exercise chain reaches schema v7 without clearing completed history or the draft. The
current host suite covers exercise
shapes and raw partial text, fresh repository restore, remove/discard, atomic shapes and raw partial text, fresh repository restore, remove/discard, atomic
finalization and repeated-finalize rejection, rollback, catalog reconciliation, finalization and repeated-finalize rejection, rollback, catalog reconciliation,
missing-selection recovery, explicit DB-open failure and historical migration. missing-selection recovery, explicit DB-open failure, historical migration,
equipment selection and occurrence identity.
```bash ```bash
cd android cd android

View file

@ -0,0 +1,41 @@
"""Exercise the real desktop companion exporter/importer on temporary DBs."""
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def make_db(path, equipment):
db = sqlite3.connect(path)
db.executescript("""
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE, session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
""")
db.execute("INSERT INTO exercises VALUES(1,'ex_fixture')")
db.execute("INSERT INTO sessions VALUES(1,'se_fixture','2026-01-01T00:00:00+00:00')")
db.execute("INSERT INTO session_exercises VALUES(1,'sxe_fixture',1,1,0,?)", (equipment,))
db.execute("PRAGMA user_version=7")
db.commit(); db.close()
def main():
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json'
make_db(source, 'leg_press'); make_db(target, None)
subprocess.run([sys.executable, ROOT / 'tools/export_equipment_associations.py', artifact, '--database', source], check=True)
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
payload = json.loads(artifact.read_text()); payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_fixture','state':'cleared'}
artifact.write_text(json.dumps(payload))
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None
print('PASS equipment association exchange')
if __name__ == '__main__': main()

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
import json
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IMPORTER = ROOT / "tools/import_mobile_export.py"
EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.py"
CATALOG_EXPORTER = ROOT / "tools/export_pc_catalog.py"
MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py"
EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.py"
SCHEMA = """
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
PRAGMA user_version=7;
"""
def payload():
catalog = [
{"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0},
{"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0},
]
entries = [
{"entry_id":"sxe_walk_10","position":0,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":600}},
{"entry_id":"sxe_push","position":1,"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":"leg_press","sets":[{"reps":12,"weight_kg":20.0}]},
{"entry_id":"sxe_walk_15","position":2,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":900}},
]
return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[]}
def invoke(path, db):
result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True)
if result.returncode: raise AssertionError(result.stdout + result.stderr)
return result.stdout
def invoke_equipment(path, db):
result = subprocess.run(
[sys.executable, str(EQUIPMENT_IMPORTER), str(path), "--database", str(db)],
text=True,
capture_output=True,
)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
return result.stdout
def main():
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
equipment = root / "equipment-v2.json"
con = sqlite3.connect(db); con.executescript(SCHEMA); con.close()
artifact.write_text(json.dumps(payload()), encoding="utf-8")
assert "sessions_imported=1" in invoke(artifact, db)
con = sqlite3.connect(db)
rows = con.execute("SELECT e.exercise_id,se.entry_id,se.position,ca.duration_seconds FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id ORDER BY se.position").fetchall()
assert rows == [("ex_walk","sxe_walk_10",0,600),("ex_push","sxe_push",1,None),("ex_walk","sxe_walk_15",2,900)], rows
assert con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0
con.close()
equipment.write_text(json.dumps({
"format": "trainlog-equipment-associations",
"version": 2,
"generated_at": "2026-09-07T10:00:00+02:00",
"associations": [
{"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "set", "equipment_id": "treadmill"},
{"session_id": "se_multi", "entry_id": "sxe_push", "exercise_id": "ex_push", "state": "set", "equipment_id": "leg_press"},
{"session_id": "se_multi", "entry_id": "sxe_walk_15", "exercise_id": "ex_walk", "state": "cleared"},
],
}), encoding="utf-8")
# Regression for the real failure: the historic TUI invocation passed
# only the artifact, while the importer requires the target database.
rejected = subprocess.run(
[sys.executable, str(EQUIPMENT_IMPORTER), str(equipment)],
text=True,
capture_output=True,
)
assert rejected.returncode != 0
assert "the following arguments are required: --database" in rejected.stderr
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
con = sqlite3.connect(db)
rows = con.execute(
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
).fetchall()
assert rows == [
("sxe_walk_10", "treadmill"),
("sxe_push", "leg_press"),
("sxe_walk_15", None),
], rows
con.close()
assert "sessions_reconciled=1" in invoke(artifact, db)
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
con = sqlite3.connect(db)
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
assert con.execute(
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
).fetchall() == rows
con.close()
catalog_output = root / "pc-catalog.json"
mobile_output = root / "pc-mobile-v2.json"
equipment_output = root / "pc-equipment-v2.json"
for tool, output, marker in (
(CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"),
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
):
result = subprocess.run(
[sys.executable, str(tool), str(output), "--database", str(db)],
text=True,
capture_output=True,
)
assert result.returncode == 0, result.stdout + result.stderr
assert marker in result.stdout
print("PASS mobile_import_multi_occurrence")
if __name__ == "__main__": main()

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Export stable desktop session/exercise equipment associations."""
import argparse
import json
import sqlite3
import os
from datetime import datetime
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path,
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
args = parser.parse_args()
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
raise ValueError("schema desktop v7 requis")
rows = connection.execute(
"SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se "
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
"ORDER BY s.started_at,s.id,se.position").fetchall()
payload = {"format": "trainlog-equipment-associations", "version": 2,
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
for session_id, entry_id, exercise_id, equipment_id in rows:
item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id,
"state": "set" if equipment_id is not None else "cleared"}
if equipment_id is not None:
item["equipment_id"] = equipment_id
payload["associations"].append(item)
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("EQUIPMENT_ASSOCIATIONS_EXPORT=PASS")
print(f"associations={len(rows)}")
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EQUIPMENT_ASSOCIATIONS_EXPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -61,7 +61,7 @@ def main():
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
if version != 5: if version != 7:
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

66
tools/export_pc_mobile.py Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Publish desktop sessions through the occurrence-aware mobile export V2."""
import argparse
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
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()
con = sqlite3.connect(args.database)
con.row_factory = sqlite3.Row
try:
if con.execute("PRAGMA user_version").fetchone()[0] != 7:
raise ValueError("schema desktop v7 requis")
root = {"format": "trainlog-mobile-export", "version": 2,
"generated_at": datetime.now().astimezone().isoformat(),
"exercises": [], "sessions": [], "body_observations": []}
for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"):
root["exercises"].append(dict(row))
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
payload["exercises"] = []
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
# retain their stable entry_id but do not duplicate that field.
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position"
for entry in con.execute(sql, (session["id"],)):
item = {"entry_id": entry["entry_id"], "position": entry["position"],
"exercise_id": entry["exercise_id"], "name": entry["name"],
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
"data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0,
"equipment_id": entry["equipment_id"]}
if entry["recording_mode"] == "continuous":
activity = con.execute("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (entry["id"],)).fetchone()
if activity is None: raise ValueError("activité continue absente")
item["continuous"] = {key: activity[key] for key in activity.keys() if activity[key] is not None}
else:
item["sets"] = []
for value in con.execute("SELECT reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (entry["id"],)):
metric = "reps" if entry["tracking_mode"] == "reps" else "duration_seconds"
set_value = {metric: value[metric]}
if value["weight_kg"] is not None: set_value["weight_kg"] = value["weight_kg"]
item["sets"].append(set_value)
payload["exercises"].append(item)
root["sessions"].append(payload)
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print("PC_MOBILE_EXPORT=PASS")
print("sessions=" + str(len(root["sessions"])))
finally:
con.close()
if __name__ == "__main__":
try: main()
except Exception as exc:
print("PC_MOBILE_EXPORT=FAIL " + str(exc))
raise SystemExit(1)

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Generate the desktop C catalogue from the one versioned JSON source."""
import json
import re
import sys
src, out = sys.argv[1:]
root = json.load(open(src, encoding="utf-8"))
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
raise SystemExit("unsupported equipment catalogue")
items = root.get("equipment")
if not isinstance(items, list):
raise SystemExit("equipment must be a list")
ids = set()
for item in items:
required = ("id", "label_name", "display_name", "aliases", "type", "load_semantics")
if not all(key in item for key in required) or not isinstance(item["aliases"], list):
raise SystemExit("invalid equipment entry")
if not item["id"] or item["id"] in ids:
raise SystemExit("empty or duplicate equipment id")
ids.add(item["id"])
if item["load_semantics"] not in {"external", "assistance", "bodyweight", "cardio"}:
raise SystemExit("invalid load semantics")
for relation in root.get("exercise_equipment", []):
if relation.get("equipment_id") not in ids:
raise SystemExit("relation references unknown equipment")
def c(value):
return json.dumps(value, ensure_ascii=False)
with open(out, "w", encoding="utf-8") as f:
f.write('#include "trainlog/equipment_catalog.h"\n#include <string.h>\n\n')
f.write('static const TrainlogEquipment entries[] = {\n')
for item in items:
f.write(' {%s, %s, %s, %s, %s, %s},\n' % (
c(item['id']), c(item['label_name']), c(item['display_name']),
c(item['type']), c(item['load_semantics']), c('\n'.join(item['aliases']))))
f.write('};\nstatic const TrainlogExerciseEquipmentRelation relations[] = {\n')
for rel in root.get('exercise_equipment', []):
f.write(' {%s, %s, %s},\n' % (c(rel['exercise_id']), c(rel['equipment_id']), c(rel['load_semantics'])))
f.write('};\n')
f.write(r'''
static int folded_contains(const char *haystack, const char *needle) {
size_t i, j;
if (needle[0] == '\0') return 1;
for (i = 0; haystack[i] != '\0'; ++i) {
for (j = 0; needle[j] != '\0'; ++j) {
unsigned char a = (unsigned char)haystack[i + j];
unsigned char b = (unsigned char)needle[j];
if (a >= 'A' && a <= 'Z') a = (unsigned char)(a + ('a' - 'A'));
if (b >= 'A' && b <= 'Z') b = (unsigned char)(b + ('a' - 'A'));
if (a == '\0' || a != b) break;
}
if (needle[j] == '\0') return 1;
}
return 0;
}
size_t trainlog_equipment_catalog_count(void) { return sizeof(entries) / sizeof(entries[0]); }
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t i) { return i < trainlog_equipment_catalog_count() ? &entries[i] : NULL; }
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *id) {
size_t i; if (id == NULL) return NULL;
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (strcmp(entries[i].equipment_id, id) == 0) return &entries[i];
return NULL;
}
size_t trainlog_equipment_catalog_search(const char *q, const TrainlogEquipment **out, size_t cap) {
size_t i, found = 0; if (q == NULL || out == NULL) return 0;
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (folded_contains(entries[i].label_name, q) || folded_contains(entries[i].display_name, q) || folded_contains(entries[i].aliases, q)) { if (found < cap) out[found] = &entries[i]; ++found; }
return found;
}
size_t trainlog_equipment_catalog_relation_count(void) { return sizeof(relations) / sizeof(relations[0]); }
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t i) { return i < trainlog_equipment_catalog_relation_count() ? &relations[i] : NULL; }
''')

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Apply the versioned equipment companion artifact to a desktop database."""
import argparse
import json
import sqlite3
from pathlib import Path
FORMAT = "trainlog-equipment-associations"
VERSION = 2
def fail(message):
raise ValueError(message)
def load_catalog(path):
root = json.loads(path.read_text(encoding="utf-8"))
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
fail("catalogue équipement non supporté")
return {item["id"] for item in root["equipment"]}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("artifact", type=Path)
parser.add_argument("--database", type=Path, required=True)
parser.add_argument("--catalog", type=Path,
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
args = parser.parse_args()
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
fail("extension équipement non supportée")
if set(payload) != {"format", "version", "generated_at", "associations"}:
fail("clés extension équipement invalides")
known = load_catalog(args.catalog)
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
fail("schema desktop v7 requis")
seen = set()
with connection:
for item in payload["associations"]:
if not isinstance(item, dict):
fail("association invalide")
session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state"))
key = (session_id, entry_id)
if not isinstance(session_id, str) or not session_id or not isinstance(entry_id, str) or not entry_id or not isinstance(exercise_id, str) or not exercise_id or key in seen:
fail("identité association invalide ou dupliquée")
seen.add(key)
if state == "set":
if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}:
fail("association set invalide")
equipment_id = item["equipment_id"]
if not isinstance(equipment_id, str) or equipment_id not in known:
fail(f"équipement inconnu: {equipment_id}")
elif state == "cleared":
if set(item) != {"session_id", "entry_id", "exercise_id", "state"}:
fail("association cleared invalide")
equipment_id = None
else:
fail("état association invalide")
cursor = connection.execute(
"UPDATE session_exercises SET equipment_id=? WHERE id=("
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
"WHERE s.session_id=? AND se.entry_id=?)",
(equipment_id, session_id, entry_id))
if cursor.rowcount != 1:
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
print(f"associations={len(seen)}")
finally:
connection.close()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"EQUIPMENT_ASSOCIATIONS_IMPORT=FAIL {error}")
raise SystemExit(1)

View file

@ -48,6 +48,10 @@ SESSION_EXERCISE_KEYS = {
"continuous", "continuous",
} }
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
"entry_id", "position", "equipment_id"
}
BODY_BASE_KEYS = { BODY_BASE_KEYS = {
"observation_id", "observation_id",
"observed_at", "observed_at",
@ -209,7 +213,7 @@ def load_payload(path):
"format mobile export invalide" "format mobile export invalide"
) )
if payload["version"] != VERSION: if payload["version"] not in (1, 2):
raise ImportFailure( raise ImportFailure(
"version mobile export non supportée" "version mobile export non supportée"
) )
@ -322,10 +326,11 @@ def validate_set_item(
tracking_mode, tracking_mode,
label, label,
): ):
allowed_weight = {"weight_kg"}
if tracking_mode == "reps": if tracking_mode == "reps":
require_exact_keys( require_exact_keys(
value, value,
{"reps"}, {"reps"} | allowed_weight,
{"reps"}, {"reps"},
label, label,
) )
@ -341,7 +346,7 @@ def validate_set_item(
require_exact_keys( require_exact_keys(
value, value,
{"duration_seconds"}, {"duration_seconds"} | allowed_weight,
{"duration_seconds"}, {"duration_seconds"},
label, label,
) )
@ -361,14 +366,21 @@ def validate_session_exercise(
label, label,
known_exercise_ids, known_exercise_ids,
): ):
is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item
require_exact_keys( require_exact_keys(
item, item,
SESSION_EXERCISE_KEYS, V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS,
SESSION_EXERCISE_KEYS (V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
- {"sets", "continuous"}, - {"sets", "continuous"},
label, label,
) )
if is_v2:
require_nonempty_string(item["entry_id"], f"{label}.entry_id")
require_int(item["position"], 0, 100000, f"{label}.position")
if item["equipment_id"] is not None:
require_nonempty_string(item["equipment_id"], f"{label}.equipment_id")
exercise_id = require_nonempty_string( exercise_id = require_nonempty_string(
item["exercise_id"], item["exercise_id"],
f"{label}.exercise_id", f"{label}.exercise_id",
@ -492,6 +504,8 @@ def validate_session_exercise(
) )
for set_index, set_item in enumerate(sets): for set_index, set_item in enumerate(sets):
if not is_v2 and "weight_kg" in set_item:
raise ImportFailure(f"{label}.sets[{set_index}]: poids interdit en v1")
validate_set_item( validate_set_item(
set_item, set_item,
tracking_mode, tracking_mode,
@ -553,6 +567,7 @@ def validate_sessions(
) )
seen_session_exercises = set() seen_session_exercises = set()
seen_positions = set()
for exercise_index, exercise in enumerate( for exercise_index, exercise in enumerate(
exercises exercises
@ -568,15 +583,20 @@ def validate_sessions(
) )
exercise_id = exercise["exercise_id"] exercise_id = exercise["exercise_id"]
identity = exercise.get("entry_id", exercise_id)
if exercise_id in seen_session_exercises: if identity in seen_session_exercises:
raise ImportFailure( raise ImportFailure(
f"{exercise_label}: exercice dupliqué dans la séance" f"{exercise_label}: identité d'entrée dupliquée dans la séance"
) )
seen_session_exercises.add( seen_session_exercises.add(
exercise_id identity
) )
if "position" in exercise:
if exercise["position"] in seen_positions:
raise ImportFailure(f"{exercise_label}: position dupliquée")
seen_positions.add(exercise["position"])
def validate_body(payload): def validate_body(payload):
@ -647,9 +667,9 @@ def require_schema_v5(connection):
"PRAGMA user_version;" "PRAGMA user_version;"
).fetchone()[0] ).fetchone()[0]
if version != 5: if version not in (5, 6, 7):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 attendue, version trouvée: {version}" f"base desktop schema v5, v6 ou v7 attendue, version trouvée: {version}"
) )
@ -874,10 +894,21 @@ def import_set_session_exercise(
sets = item["sets"] sets = item["sets"]
tracking = item["tracking_mode"] tracking = item["tracking_mode"]
entry_id = item.get("entry_id")
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if entry_id is None and schema_version >= 7:
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
columns = "entry_id, " if entry_id is not None else ""
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO session_exercises( INSERT INTO session_exercises(
session_row_id, """ + columns + """session_row_id,
exercise_row_id, exercise_row_id,
recording_mode, recording_mode,
data_fields, data_fields,
@ -887,19 +918,13 @@ def import_set_session_exercise(
target_sets, target_sets,
target_reps, target_reps,
target_duration_seconds, target_duration_seconds,
target_weight_kg, target_weight_kg, notes""" + equipment_columns + """
notes
) VALUES( ) VALUES(
?, ?, 'sets', ?, ?, 'none', 0, """ + values + """?, ?, 'sets', ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
); );
""", """,
( arguments,
session_row_id,
exercise_row,
item["data_fields"],
position,
),
) )
session_exercise_row_id = ( session_exercise_row_id = (
@ -924,13 +949,13 @@ def import_set_session_exercise(
reps, reps,
duration_seconds, duration_seconds,
weight_kg weight_kg
) VALUES(?, ?, ?, ?, NULL); ) VALUES(?, ?, ?, ?, ?);
""", """,
( (
session_exercise_row_id, session_exercise_row_id,
set_index, set_index,
reps, reps,
duration, duration, set_item.get("weight_kg"),
), ),
) )
@ -941,10 +966,21 @@ def import_continuous_session_exercise(
item, item,
exercise_row, exercise_row,
): ):
entry_id = item.get("entry_id")
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if entry_id is None and schema_version >= 7:
entry_id = "sxe_v1_" + str(session_row_id) + "_" + item["exercise_id"]
columns = "entry_id, " if entry_id is not None else ""
values = "?, " if entry_id is not None else ""
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
equipment_values = ", ?" if schema_version >= 6 else ""
arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position]
if schema_version >= 6:
arguments.append(item.get("equipment_id"))
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO session_exercises( INSERT INTO session_exercises(
session_row_id, """ + columns + """session_row_id,
exercise_row_id, exercise_row_id,
recording_mode, recording_mode,
data_fields, data_fields,
@ -954,19 +990,13 @@ def import_continuous_session_exercise(
target_sets, target_sets,
target_reps, target_reps,
target_duration_seconds, target_duration_seconds,
target_weight_kg, target_weight_kg, notes""" + equipment_columns + """
notes
) VALUES( ) VALUES(
?, ?, 'continuous', ?, ?, 'none', 0, """ + values + """?, ?, 'continuous', ?, ?, 'none', 0,
NULL, NULL, NULL, NULL, NULL NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
); );
""", """,
( arguments,
session_row_id,
exercise_row,
item["data_fields"],
position,
),
) )
continuous = item["continuous"] continuous = item["continuous"]
@ -1000,9 +1030,30 @@ def import_sessions(
connection, connection,
session["session_id"], session["session_id"],
): ):
if payload["version"] == 1:
report["sessions_skipped"] += 1 report["sessions_skipped"] += 1
continue continue
existing = connection.execute(
"SELECT s.id FROM sessions s WHERE s.session_id=?;",
(session["session_id"],)).fetchone()
session_row_id = existing[0]
incoming = [(x["entry_id"], x["exercise_id"]) for x in session["exercises"]]
rows = connection.execute(
"SELECT se.entry_id,e.exercise_id FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position;",
(session_row_id,)).fetchall()
current = [(row[0], row[1]) for row in rows]
legacy = all(value[0].startswith("sxe_legacy_") or value[0].startswith("sxe_v1_") for value in current)
# A v1 history may be upgraded only when exercise/order mapping is
# unique. Any other identity disagreement is an explicit conflict.
if current != incoming and not (legacy and [x[1] for x in current] == [x[1] for x in incoming]):
raise ImportFailure("conflit d'identités d'entrées pour " + session["session_id"])
# Explicit child deletion makes reconciliation safe even for old
# databases which were created without enforced foreign keys.
connection.execute("DELETE FROM performed_sets WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM continuous_activity WHERE session_exercise_row_id IN (SELECT id FROM session_exercises WHERE session_row_id=?);", (session_row_id,))
connection.execute("DELETE FROM session_exercises WHERE session_row_id=?;", (session_row_id,))
report["sessions_reconciled"] += 1
else:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO sessions( INSERT INTO sessions(
@ -1025,6 +1076,7 @@ def import_sessions(
for position, item in enumerate( for position, item in enumerate(
session["exercises"] session["exercises"]
): ):
position = item.get("position", position)
mobile_id = item["exercise_id"] mobile_id = item["exercise_id"]
desktop_id = exercise_mapping.get( desktop_id = exercise_mapping.get(
@ -1188,6 +1240,7 @@ def run_import(
"exercises_reconciled": 0, "exercises_reconciled": 0,
"exercises_skipped": 0, "exercises_skipped": 0,
"sessions_imported": 0, "sessions_imported": 0,
"sessions_reconciled": 0,
"sessions_skipped": 0, "sessions_skipped": 0,
"body_imported": 0, "body_imported": 0,
"body_skipped": 0, "body_skipped": 0,
@ -1255,6 +1308,7 @@ def print_report(
"exercises_reconciled", "exercises_reconciled",
"exercises_skipped", "exercises_skipped",
"sessions_imported", "sessions_imported",
"sessions_reconciled",
"sessions_skipped", "sessions_skipped",
"body_imported", "body_imported",
"body_skipped", "body_skipped",

View file

@ -210,10 +210,7 @@ def validate_semantics(document: dict[str, Any]) -> None:
validate_load_mode(workout, index) validate_load_mode(workout, index)
if "notes" in workout: if "notes" in workout:
require_non_blank( require_non_blank(workout["notes"], f"session.exercises[{index}].notes")
workout["notes"],
f"session.exercises[{index}].notes",
)
catalog_ids = set(catalog_by_id) catalog_ids = set(catalog_by_id)
if catalog_ids != workout_ids: if catalog_ids != workout_ids:
@ -224,11 +221,39 @@ def validate_semantics(document: dict[str, Any]) -> None:
details.append(f"unreferenced catalog ids: {unreferenced}") details.append(f"unreferenced catalog ids: {unreferenced}")
if missing: if missing:
details.append(f"missing catalog ids: {missing}") details.append(f"missing catalog ids: {missing}")
raise TrainlogSemanticError( raise TrainlogSemanticError("catalog/reference set mismatch: " + "; ".join(details))
"catalog/reference set mismatch: " + "; ".join(details)
)
def validate_mobile_export_v2(document: Any) -> None:
"""Validate occurrence identity/order without weakening frozen v1 rules."""
if not isinstance(document, dict) or document.get("format") != "trainlog-mobile-export" or document.get("version") != 2:
raise TrainlogSemanticError("mobile export V2: format/version invalid")
catalog = document.get("exercises")
sessions = document.get("sessions")
if not isinstance(catalog, list) or not isinstance(sessions, list):
raise TrainlogSemanticError("mobile export V2: arrays required")
ids = {item.get("exercise_id") for item in catalog if isinstance(item, dict)}
if len(ids) != len(catalog) or None in ids:
raise TrainlogSemanticError("mobile export V2: duplicate/invalid catalogue identity")
seen_sessions: set[str] = set()
for session in sessions:
if not isinstance(session, dict) or not isinstance(session.get("session_id"), str) or not session["session_id"]:
raise TrainlogSemanticError("mobile export V2: invalid session identity")
if session["session_id"] in seen_sessions:
raise TrainlogSemanticError("mobile export V2: duplicate session identity")
seen_sessions.add(session["session_id"])
entries = session.get("exercises")
if not isinstance(entries, list):
raise TrainlogSemanticError("mobile export V2: entries array required")
entry_ids: set[str] = set(); positions: set[int] = set()
for entry in entries:
if not isinstance(entry, dict) or not isinstance(entry.get("entry_id"), str) or not entry["entry_id"]:
raise TrainlogSemanticError("mobile export V2: invalid entry identity")
if entry["entry_id"] in entry_ids or entry.get("exercise_id") not in ids:
raise TrainlogSemanticError("mobile export V2: duplicate entry or unknown exercise")
if isinstance(entry.get("position"), bool) or not isinstance(entry.get("position"), int) or entry["position"] < 0 or entry["position"] in positions:
raise TrainlogSemanticError("mobile export V2: invalid/duplicate entry position")
entry_ids.add(entry["entry_id"]); positions.add(entry["position"])
def structural_errors( def structural_errors(
validator: jsonschema.Draft202012Validator, validator: jsonschema.Draft202012Validator,
document: Any, document: Any,
@ -259,11 +284,16 @@ def validate_document(
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
return [str(exc)] return [str(exc)]
is_mobile_v2 = isinstance(document, dict) and document.get("format") == "trainlog-mobile-export" and document.get("version") == 2
if not is_mobile_v2:
errors = structural_errors(validator, document) errors = structural_errors(validator, document)
if errors: if errors:
return errors return errors
try: try:
if is_mobile_v2:
validate_mobile_export_v2(document)
else:
validate_semantics(document) validate_semantics(document)
except TrainlogSemanticError as exc: except TrainlogSemanticError as exc:
return [str(exc)] return [str(exc)]

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 5 #define TRAINLOG_DATABASE_SCHEMA_VERSION 7
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;
@ -20,6 +20,22 @@ TrainlogStatus trainlog_database_open(
TrainlogDatabase **output_database TrainlogDatabase **output_database
); );
/**
* @brief Open a database and report the backend failure that prevented it.
*
* CONTRACT: @p output_diagnostic is optional. When supplied with a non-zero
* capacity, it receives a NUL-terminated explanation of the failed SQLite
* operation; callers still use the returned TrainlogStatus for control flow.
* This preserves the stable application status surface without hiding the
* path-specific reason needed to repair a user's durable database safely.
*/
TrainlogStatus trainlog_database_open_with_diagnostic(
const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity
);
void trainlog_database_close(TrainlogDatabase *database); void trainlog_database_close(TrainlogDatabase *database);
TrainlogStatus trainlog_database_schema_version( TrainlogStatus trainlog_database_schema_version(
@ -107,7 +123,10 @@ TrainlogStatus trainlog_database_list_weight_points(
#define TRAINLOG_SET_SUMMARY_MAX 1024U #define TRAINLOG_SET_SUMMARY_MAX 1024U
typedef struct TrainlogPersistedExerciseDetail { typedef struct TrainlogPersistedExerciseDetail {
/* Stable occurrence identity; exercise_id is catalogue identity only. */
char entry_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogRecordingMode recording_mode; TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields; TrainlogExerciseDataFields data_fields;
@ -231,7 +250,9 @@ TrainlogStatus trainlog_database_list_exercise_performance(
/* TRAINLOG_SESSION_EDIT_API */ /* TRAINLOG_SESSION_EDIT_API */
typedef struct TrainlogEditableExerciseRecord { typedef struct TrainlogEditableExerciseRecord {
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U]; char exercise_id[TRAINLOG_ID_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U]; char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode; TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;

View file

@ -0,0 +1,31 @@
#ifndef TRAINLOG_EQUIPMENT_CATALOG_H
#define TRAINLOG_EQUIPMENT_CATALOG_H
#include <stddef.h>
/* CONTRACT: data is generated at build time from catalog/equipment-v1.json.
* Callers borrow returned strings for process lifetime and must not free them. */
typedef struct TrainlogEquipment {
const char *equipment_id;
const char *label_name;
const char *display_name;
const char *equipment_type;
const char *load_semantics;
const char *aliases; /* newline-separated canonical aliases */
} TrainlogEquipment;
typedef struct TrainlogExerciseEquipmentRelation {
const char *exercise_id;
const char *equipment_id;
const char *load_semantics;
} TrainlogExerciseEquipmentRelation;
size_t trainlog_equipment_catalog_count(void);
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t index);
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *equipment_id);
/* Search is deterministic and ASCII-case-insensitive over label, display and aliases. */
size_t trainlog_equipment_catalog_search(const char *query, const TrainlogEquipment **output, size_t capacity);
size_t trainlog_equipment_catalog_relation_count(void);
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t index);
#endif

View file

@ -60,7 +60,11 @@ typedef struct TrainlogSetInput {
} TrainlogSetInput; } TrainlogSetInput;
typedef struct TrainlogSessionExerciseInput { typedef struct TrainlogSessionExerciseInput {
/* Empty means local creation; the database allocates a stable sxe UUID. */
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U]; char exercise_id[TRAINLOG_ID_MAX + 1U];
/* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogRecordingMode recording_mode; TrainlogRecordingMode recording_mode;
TrainlogExerciseDataFields data_fields; TrainlogExerciseDataFields data_fields;
TrainlogLoadMode load_mode; TrainlogLoadMode load_mode;

View file

@ -16,6 +16,13 @@ m_dep = cc.find_library('m', required: true)
trainlog_include = include_directories('include') trainlog_include = include_directories('include')
equipment_catalog_generated = custom_target(
'equipment_catalog_generated',
input: meson.project_source_root() / 'catalog/equipment-v1.json',
output: 'equipment_catalog_generated.c',
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_equipment_catalog.py', '@INPUT@', '@OUTPUT@'],
)
strict_c_args = [ strict_c_args = [
'-D_POSIX_C_SOURCE=200809L', '-D_POSIX_C_SOURCE=200809L',
'-Wconversion', '-Wconversion',
@ -37,6 +44,7 @@ trainlog_core_sources = files(
'src/measured_max.c', 'src/measured_max.c',
'src/sync.c', 'src/sync.c',
) )
trainlog_core_sources += equipment_catalog_generated
trainlog_core = static_library( trainlog_core = static_library(
'trainlog_core', 'trainlog_core',
@ -108,6 +116,14 @@ test(
test_catalog, test_catalog,
) )
test_equipment_catalog = executable(
'test_equipment_catalog',
'tests/test_equipment_catalog.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('equipment_catalog', test_equipment_catalog)
test_session_detail = executable( test_session_detail = executable(
'test_session_detail', 'test_session_detail',
'tests/test_session_detail.c', 'tests/test_session_detail.c',
@ -335,6 +351,18 @@ test(
], ],
) )
test(
'mobile_import_multi_occurrence',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_mobile_import_multi_occurrence.py'],
)
test(
'equipment_associations_exchange',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_equipment_associations_exchange.py'],
)
trainlog_sync_once = executable( trainlog_sync_once = executable(
'trainlog-sync-once', 'trainlog-sync-once',
'tools/sync_once.c', 'tools/sync_once.c',

View file

@ -5,6 +5,8 @@
#include "trainlog/database.h" #include "trainlog/database.h"
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/equipment_catalog.h"
#include "trainlog/id.h"
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
@ -17,7 +19,27 @@ struct TrainlogDatabase {
sqlite3 *connection; sqlite3 *connection;
}; };
static const char *const SCHEMA_V5_SQL_A = static void set_open_diagnostic(
char *output,
size_t capacity,
const char *operation,
sqlite3 *connection,
int sqlite_status
)
{
const char *message;
if (output == NULL || capacity == 0U) {
return;
}
message = connection != NULL
? sqlite3_errmsg(connection)
: sqlite3_errstr(sqlite_status);
(void)snprintf(output, capacity, "%s: %s", operation, message);
}
static const char *const SCHEMA_V7_SQL_A =
"BEGIN IMMEDIATE;" "BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS exercises (" "CREATE TABLE IF NOT EXISTS exercises ("
@ -47,6 +69,7 @@ static const char *const SCHEMA_V5_SQL_A =
"CREATE TABLE IF NOT EXISTS session_exercises (" "CREATE TABLE IF NOT EXISTS session_exercises ("
" id INTEGER PRIMARY KEY," " id INTEGER PRIMARY KEY,"
" entry_id TEXT NOT NULL UNIQUE,"
" session_row_id INTEGER NOT NULL" " session_row_id INTEGER NOT NULL"
" REFERENCES sessions(id) ON DELETE CASCADE," " REFERENCES sessions(id) ON DELETE CASCADE,"
" exercise_row_id INTEGER NOT NULL" " exercise_row_id INTEGER NOT NULL"
@ -64,9 +87,9 @@ static const char *const SCHEMA_V5_SQL_A =
" target_duration_seconds INTEGER" " target_duration_seconds INTEGER"
" CHECK (target_duration_seconds > 0)," " CHECK (target_duration_seconds > 0),"
" target_weight_kg REAL CHECK (target_weight_kg > 0.0)," " target_weight_kg REAL CHECK (target_weight_kg > 0.0),"
" equipment_id TEXT,"
" notes TEXT," " notes TEXT,"
" UNIQUE (session_row_id, position)," " UNIQUE (session_row_id, position),"
" UNIQUE (session_row_id, exercise_row_id),"
" CHECK (" " CHECK ("
" (recording_mode = 'sets' AND" " (recording_mode = 'sets' AND"
" (" " ("
@ -109,7 +132,7 @@ static const char *const SCHEMA_V5_SQL_A =
" )" " )"
");"; ");";
static const char *const SCHEMA_V5_SQL_B = static const char *const SCHEMA_V7_SQL_B =
"CREATE TABLE IF NOT EXISTS continuous_activity (" "CREATE TABLE IF NOT EXISTS continuous_activity ("
" id INTEGER PRIMARY KEY," " id INTEGER PRIMARY KEY,"
" session_exercise_row_id INTEGER NOT NULL UNIQUE" " session_exercise_row_id INTEGER NOT NULL UNIQUE"
@ -152,9 +175,41 @@ static const char *const SCHEMA_V5_SQL_B =
" )" " )"
");" ");"
"PRAGMA user_version = 5;" "PRAGMA user_version = 7;"
"COMMIT;"; "COMMIT;";
static const char *const MIGRATE_V5_TO_V6_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE session_exercises ADD COLUMN equipment_id TEXT;"
"PRAGMA user_version = 6;"
"COMMIT;";
/* WHY: a catalogue exercise can occur twice in one completed session. v7
* introduces a persistent occurrence ID and removes the invalid uniqueness
* constraint while retaining primary keys and all dependent measurements. */
static const char *const MIGRATE_V6_TO_V7_SQL =
"PRAGMA foreign_keys = OFF;"
"BEGIN IMMEDIATE;"
"ALTER TABLE session_exercises RENAME TO session_exercises_v6;"
"CREATE TABLE session_exercises ("
"id INTEGER PRIMARY KEY, entry_id TEXT NOT NULL UNIQUE,"
"session_row_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,"
"exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE RESTRICT,"
"recording_mode TEXT NOT NULL, data_fields INTEGER NOT NULL,"
"position INTEGER NOT NULL, load_mode TEXT NOT NULL, rest_seconds INTEGER NOT NULL,"
"target_sets INTEGER, target_reps INTEGER, target_duration_seconds INTEGER,"
"target_weight_kg REAL, equipment_id TEXT, notes TEXT, UNIQUE(session_row_id,position));"
"INSERT INTO session_exercises(id,entry_id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,equipment_id,notes) "
"SELECT id,'sxe_legacy_' || id,session_row_id,exercise_row_id,recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,equipment_id,notes FROM session_exercises_v6;"
"ALTER TABLE performed_sets RENAME TO performed_sets_v6;"
"CREATE TABLE performed_sets (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id) ON DELETE CASCADE,position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL,UNIQUE(session_exercise_row_id,position));"
"INSERT INTO performed_sets SELECT * FROM performed_sets_v6;"
"ALTER TABLE continuous_activity RENAME TO continuous_activity_v6;"
"CREATE TABLE continuous_activity (id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);"
"INSERT INTO continuous_activity SELECT * FROM continuous_activity_v6;"
"DROP TABLE performed_sets_v6;DROP TABLE continuous_activity_v6;DROP TABLE session_exercises_v6;"
"PRAGMA user_version = 7;COMMIT;PRAGMA foreign_keys = ON;";
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 "
@ -489,7 +544,9 @@ static TrainlogStatus read_single_int_pragma(
} }
static TrainlogStatus initialize_or_validate_schema( static TrainlogStatus initialize_or_validate_schema(
TrainlogDatabase *database TrainlogDatabase *database,
char *output_diagnostic,
size_t output_diagnostic_capacity
) )
{ {
int version = 0; int version = 0;
@ -505,6 +562,13 @@ static TrainlogStatus initialize_or_validate_schema(
status != status !=
TRAINLOG_STATUS_OK TRAINLOG_STATUS_OK
) { ) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"read schema version",
database->connection,
SQLITE_ERROR
);
return status; return status;
} }
@ -512,6 +576,13 @@ static TrainlogStatus initialize_or_validate_schema(
version > version >
TRAINLOG_DATABASE_SCHEMA_VERSION TRAINLOG_DATABASE_SCHEMA_VERSION
) { ) {
(void)snprintf(
output_diagnostic,
output_diagnostic_capacity,
"schema version %d is newer than supported version %d",
version,
TRAINLOG_DATABASE_SCHEMA_VERSION
);
return return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
} }
@ -527,7 +598,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = status =
execute_sql( execute_sql(
database, database,
SCHEMA_V5_SQL_A SCHEMA_V7_SQL_A
); );
if ( if (
@ -537,7 +608,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = status =
execute_sql( execute_sql(
database, database,
SCHEMA_V5_SQL_B SCHEMA_V7_SQL_B
); );
} }
} else { } else {
@ -617,6 +688,10 @@ static TrainlogStatus initialize_or_validate_schema(
} else if (version == 4) { } else if (version == 4) {
status = status =
TRAINLOG_STATUS_OK; TRAINLOG_STATUS_OK;
} else if (version == 5) {
status = TRAINLOG_STATUS_OK;
} else if (version == 6) {
status = TRAINLOG_STATUS_OK;
} else { } else {
return return
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; TRAINLOG_STATUS_SCHEMA_UNSUPPORTED;
@ -643,12 +718,26 @@ static TrainlogStatus initialize_or_validate_schema(
MIGRATE_V4_TO_V5_SQL_B MIGRATE_V4_TO_V5_SQL_B
); );
} }
if (status == TRAINLOG_STATUS_OK && version < 6) {
status = execute_sql(database, MIGRATE_V5_TO_V6_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V6_TO_V7_SQL);
}
} }
if ( if (
status != status !=
TRAINLOG_STATUS_OK TRAINLOG_STATUS_OK
) { ) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v7" : "migrate database to schema v7",
database->connection,
SQLITE_ERROR
);
(void)sqlite3_exec( (void)sqlite3_exec(
database->connection, database->connection,
"ROLLBACK;", "ROLLBACK;",
@ -665,19 +754,51 @@ TrainlogStatus trainlog_database_open(
const char *path, const char *path,
TrainlogDatabase **output_database TrainlogDatabase **output_database
) )
{
return trainlog_database_open_with_diagnostic(
path,
output_database,
NULL,
0U
);
}
TrainlogStatus trainlog_database_open_with_diagnostic(
const char *path,
TrainlogDatabase **output_database,
char *output_diagnostic,
size_t output_diagnostic_capacity
)
{ {
TrainlogDatabase *database; TrainlogDatabase *database;
int rc; int rc;
TrainlogStatus status; TrainlogStatus status;
if (path == NULL || path[0] == '\0' || output_database == NULL) { if (path == NULL || path[0] == '\0' || output_database == NULL) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"validate database path",
NULL,
SQLITE_MISUSE
);
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
*output_database = NULL; *output_database = NULL;
if (output_diagnostic != NULL && output_diagnostic_capacity > 0U) {
output_diagnostic[0] = '\0';
}
database = calloc(1U, sizeof(*database)); database = calloc(1U, sizeof(*database));
if (database == NULL) { if (database == NULL) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"allocate database handle",
NULL,
SQLITE_NOMEM
);
return TRAINLOG_STATUS_SYSTEM_ERROR; return TRAINLOG_STATUS_SYSTEM_ERROR;
} }
@ -688,22 +809,47 @@ TrainlogStatus trainlog_database_open(
NULL NULL
); );
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"open database",
database->connection,
rc
);
trainlog_database_close(database); trainlog_database_close(database);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) { if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"configure database busy timeout",
database->connection,
SQLITE_ERROR
);
trainlog_database_close(database); trainlog_database_close(database);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
status = execute_sql(database, "PRAGMA foreign_keys = ON;"); status = execute_sql(database, "PRAGMA foreign_keys = ON;");
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
"enable foreign-key enforcement",
database->connection,
SQLITE_ERROR
);
trainlog_database_close(database); trainlog_database_close(database);
return status; return status;
} }
status = initialize_or_validate_schema(database); status = initialize_or_validate_schema(
database,
output_diagnostic,
output_diagnostic_capacity
);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); trainlog_database_close(database);
return status; return status;
@ -1415,6 +1561,8 @@ static TrainlogStatus insert_session_exercise(
sqlite3_int64 exercise_row_id; sqlite3_int64 exercise_row_id;
const char *load_mode; const char *load_mode;
const char *recording_mode; const char *recording_mode;
char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
const char *entry_id;
int rc; int rc;
TrainlogStatus status; TrainlogStatus status;
@ -1425,6 +1573,25 @@ static TrainlogStatus insert_session_exercise(
return TRAINLOG_STATUS_INVALID_ARGUMENT; return TRAINLOG_STATUS_INVALID_ARGUMENT;
} }
if (input->equipment_id[0] != '\0' &&
trainlog_equipment_catalog_lookup(input->equipment_id) == NULL) {
/* INVARIANT: a persisted occurrence only names a manifest identity. */
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
/* Local TUI creation has no transport-supplied identity; allocate it once
* at persistence, while imported V2 identities pass through unchanged. */
if (input->entry_id[0] == '\0') {
status = trainlog_id_generate("sy", generated_entry_id,
sizeof(generated_entry_id));
if (status != TRAINLOG_STATUS_OK) {
return status;
}
entry_id = generated_entry_id;
} else {
entry_id = input->entry_id;
}
load_mode = load_mode =
load_mode_to_sql( load_mode_to_sql(
input->load_mode input->load_mode
@ -1522,15 +1689,15 @@ static TrainlogStatus insert_session_exercise(
rc = sqlite3_prepare_v2( rc = sqlite3_prepare_v2(
database->connection, database->connection,
"INSERT INTO session_exercises(" "INSERT INTO session_exercises("
"session_row_id, exercise_row_id, " "entry_id, session_row_id, exercise_row_id, "
"recording_mode, data_fields, " "recording_mode, data_fields, "
"position, load_mode, rest_seconds, " "position, load_mode, rest_seconds, "
"target_sets, target_reps, " "target_sets, target_reps, "
"target_duration_seconds, " "target_duration_seconds, "
"target_weight_kg, notes" "target_weight_kg, notes, equipment_id"
") VALUES(" ") VALUES("
"?1, ?2, ?3, ?4, ?5, ?6, " "?1, ?2, ?3, ?4, ?5, ?6, ?7, "
"?7, ?8, ?9, ?10, ?11, ?12" "?8, ?9, ?10, ?11, ?12, ?13, ?14"
");", ");",
-1, -1,
&statement, &statement,
@ -1541,16 +1708,20 @@ static TrainlogStatus insert_session_exercise(
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
} }
rc = sqlite3_bind_int64( rc = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
statement,
1,
session_row_id
);
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64( rc = sqlite3_bind_int64(
statement, statement,
2, 2,
session_row_id
);
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64(
statement,
3,
exercise_row_id exercise_row_id
); );
} }
@ -1558,7 +1729,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_text( rc = sqlite3_bind_text(
statement, statement,
3, 4,
recording_mode, recording_mode,
-1, -1,
SQLITE_STATIC SQLITE_STATIC
@ -1568,7 +1739,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64( rc = sqlite3_bind_int64(
statement, statement,
4, 5,
(sqlite3_int64)input->data_fields (sqlite3_int64)input->data_fields
); );
} }
@ -1576,7 +1747,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_int64( rc = sqlite3_bind_int64(
statement, statement,
5, 6,
(sqlite3_int64)position (sqlite3_int64)position
); );
} }
@ -1584,7 +1755,7 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_text( rc = sqlite3_bind_text(
statement, statement,
6, 7,
load_mode, load_mode,
-1, -1,
SQLITE_STATIC SQLITE_STATIC
@ -1594,30 +1765,17 @@ static TrainlogStatus insert_session_exercise(
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = sqlite3_bind_int( rc = sqlite3_bind_int(
statement, statement,
7, 8,
input->rest_seconds input->rest_seconds
); );
} }
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = input->target_sets > 0 rc = input->target_sets > 0
? sqlite3_bind_int(
statement,
8,
input->target_sets
)
: sqlite3_bind_null(
statement,
8
);
}
if (rc == SQLITE_OK) {
rc = input->target_reps > 0
? sqlite3_bind_int( ? sqlite3_bind_int(
statement, statement,
9, 9,
input->target_reps input->target_sets
) )
: sqlite3_bind_null( : sqlite3_bind_null(
statement, statement,
@ -1626,11 +1784,11 @@ static TrainlogStatus insert_session_exercise(
} }
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = input->target_duration_seconds > 0 rc = input->target_reps > 0
? sqlite3_bind_int( ? sqlite3_bind_int(
statement, statement,
10, 10,
input->target_duration_seconds input->target_reps
) )
: sqlite3_bind_null( : sqlite3_bind_null(
statement, statement,
@ -1639,11 +1797,11 @@ static TrainlogStatus insert_session_exercise(
} }
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = input->target_has_weight rc = input->target_duration_seconds > 0
? sqlite3_bind_double( ? sqlite3_bind_int(
statement, statement,
11, 11,
input->target_weight_kg input->target_duration_seconds
) )
: sqlite3_bind_null( : sqlite3_bind_null(
statement, statement,
@ -1651,23 +1809,43 @@ static TrainlogStatus insert_session_exercise(
); );
} }
if (rc == SQLITE_OK) {
rc = input->target_has_weight
? sqlite3_bind_double(
statement,
12,
input->target_weight_kg
)
: sqlite3_bind_null(
statement,
12
);
}
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = rc =
input->notes != NULL && input->notes != NULL &&
input->notes[0] != '\0' input->notes[0] != '\0'
? sqlite3_bind_text( ? sqlite3_bind_text(
statement, statement,
12, 13,
input->notes, input->notes,
-1, -1,
SQLITE_TRANSIENT SQLITE_TRANSIENT
) )
: sqlite3_bind_null( : sqlite3_bind_null(
statement, statement,
12 13
); );
} }
if (rc == SQLITE_OK) {
rc = input->equipment_id[0] != '\0'
? sqlite3_bind_text(statement, 14, input->equipment_id, -1,
SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 14);
}
if (rc != SQLITE_OK) { if (rc != SQLITE_OK) {
(void)sqlite3_finalize(statement); (void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR; return TRAINLOG_STATUS_DATABASE_ERROR;
@ -2276,6 +2454,7 @@ TrainlogStatus trainlog_database_insert_body_observation(
SQLITE_TRANSIENT SQLITE_TRANSIENT
); );
} }
if (rc == SQLITE_OK) { if (rc == SQLITE_OK) {
rc = observation->session_id != NULL && rc = observation->session_id != NULL &&
observation->session_id[0] != '\0' observation->session_id[0] != '\0'
@ -2672,6 +2851,8 @@ TrainlogStatus trainlog_database_get_session_details(
"COALESCE(se.target_duration_seconds, 0), " "COALESCE(se.target_duration_seconds, 0), "
"se.target_weight_kg, " "se.target_weight_kg, "
"ca.duration_seconds, ca.speed_kmh, ca.distance_km, " "ca.duration_seconds, ca.speed_kmh, ca.distance_km, "
"se.equipment_id, "
"se.entry_id, "
"se.id " "se.id "
"FROM session_exercises AS se " "FROM session_exercises AS se "
"JOIN sessions AS s " "JOIN sessions AS s "
@ -2842,7 +3023,10 @@ TrainlogStatus trainlog_database_get_session_details(
sqlite3_column_text(exercises, 4); sqlite3_column_text(exercises, 4);
sqlite3_int64 session_exercise_row_id = sqlite3_int64 session_exercise_row_id =
sqlite3_column_int64(exercises, 13); sqlite3_column_int64(exercises, 15);
const unsigned char *equipment_id =
sqlite3_column_text(exercises, 13);
const unsigned char *entry_id = sqlite3_column_text(exercises, 14);
TrainlogStatus status; TrainlogStatus status;
@ -2865,6 +3049,17 @@ TrainlogStatus trainlog_database_get_session_details(
sizeof(*detail) sizeof(*detail)
); );
if (equipment_id != NULL) {
(void)snprintf(detail->equipment_id,
sizeof(detail->equipment_id), "%s",
(const char *)equipment_id);
}
if (entry_id == NULL) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(detail->entry_id, sizeof(detail->entry_id), "%s",
(const char *)entry_id);
(void)snprintf( (void)snprintf(
detail->name, detail->name,
sizeof(detail->name), sizeof(detail->name),
@ -3676,7 +3871,8 @@ TrainlogStatus trainlog_database_load_session_editable(
"COALESCE(se.target_reps, 0), " "COALESCE(se.target_reps, 0), "
"COALESCE(se.target_duration_seconds, 0), " "COALESCE(se.target_duration_seconds, 0), "
"se.target_weight_kg, " "se.target_weight_kg, "
"COALESCE(se.notes, '') " "COALESCE(se.notes, ''), "
"COALESCE(se.equipment_id, ''), se.entry_id "
"FROM session_exercises AS se " "FROM session_exercises AS se "
"JOIN sessions AS s " "JOIN sessions AS s "
"ON s.id = se.session_row_id " "ON s.id = se.session_row_id "
@ -3909,6 +4105,23 @@ TrainlogStatus trainlog_database_load_session_editable(
sizeof(*record) sizeof(*record)
); );
if (sqlite3_column_type(exercise_statement, 11) != SQLITE_NULL) {
const unsigned char *equipment_id = sqlite3_column_text(exercise_statement, 11);
if (equipment_id == NULL) {
(void)sqlite3_finalize(exercise_statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(record->equipment_id, sizeof(record->equipment_id),
"%s", (const char *)equipment_id);
}
if (sqlite3_column_type(exercise_statement, 12) == SQLITE_NULL) {
(void)sqlite3_finalize(exercise_statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(record->entry_id, sizeof(record->entry_id), "%s",
(const char *)sqlite3_column_text(exercise_statement, 12));
(void)snprintf( (void)snprintf(
record->exercise_id, record->exercise_id,
sizeof(record->exercise_id), sizeof(record->exercise_id),

View file

@ -83,6 +83,7 @@ static int build_database_path(char *output, size_t output_size)
int main(void) int main(void)
{ {
char database_path[PATH_MAX]; char database_path[PATH_MAX];
char database_diagnostic[256];
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
TrainlogStatus status; TrainlogStatus status;
int result; int result;
@ -92,12 +93,21 @@ int main(void)
return 1; return 1;
} }
status = trainlog_database_open(database_path, &database); status = trainlog_database_open_with_diagnostic(
database_path,
&database,
database_diagnostic,
sizeof(database_diagnostic)
);
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
(void)fprintf( (void)fprintf(
stderr, stderr,
"trainlog: unable to open database (%d)\n", "trainlog: unable to open database '%s' (status %d): %s\n",
(int)status database_path,
(int)status,
database_diagnostic[0] != '\0'
? database_diagnostic
: "no SQLite diagnostic available"
); );
return 1; return 1;
} }

View file

@ -28,11 +28,20 @@
#define SYNC_REQUEST_TEXT_MAX 4095U #define SYNC_REQUEST_TEXT_MAX 4095U
static const char *const MOBILE_EXPORT_NAME = static const char *const MOBILE_EXPORT_NAME =
"trainlog-mobile-export-v2.json";
static const char *const MOBILE_EXPORT_V1_NAME =
"trainlog-mobile-export-v1.json"; "trainlog-mobile-export-v1.json";
static const char *const PC_MOBILE_EXPORT_NAME =
"trainlog-pc-mobile-export-v2.json";
static const char *const PC_CATALOG_NAME = static const char *const PC_CATALOG_NAME =
"trainlog-pc-catalog-v1.json"; "trainlog-pc-catalog-v1.json";
static const char *const EQUIPMENT_ASSOCIATIONS_NAME =
"trainlog-equipment-associations-v2.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";
@ -40,11 +49,17 @@ static const char *const SYNC_RECEIPT_NAME =
"trainlog-sync-receipt-v1.json"; "trainlog-sync-receipt-v1.json";
static const char *const MOBILE_EXPORT_LOCAL = static const char *const MOBILE_EXPORT_LOCAL =
"/tmp/trainlog-mobile-export-v1.json"; "/tmp/trainlog-mobile-export-v2.json";
static const char *const PC_MOBILE_EXPORT_LOCAL =
"/tmp/trainlog-pc-mobile-export-v2.json";
static const char *const PC_CATALOG_LOCAL = static const char *const PC_CATALOG_LOCAL =
"/tmp/trainlog-pc-catalog-v1.json"; "/tmp/trainlog-pc-catalog-v1.json";
static const char *const EQUIPMENT_ASSOCIATIONS_LOCAL =
"/tmp/trainlog-equipment-associations-v2.json";
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";
@ -57,6 +72,9 @@ static const char *const MOBILE_IMPORT_RESULT =
static const char *const PC_CATALOG_RESULT = static const char *const PC_CATALOG_RESULT =
"/tmp/trainlog-pc-catalog-result.txt"; "/tmp/trainlog-pc-catalog-result.txt";
static const char *const EQUIPMENT_ASSOCIATIONS_RESULT =
"/tmp/trainlog-equipment-associations-result.txt";
typedef struct SyncSilence { typedef struct SyncSilence {
int saved_stdout; int saved_stdout;
int saved_stderr; int saved_stderr;
@ -947,6 +965,7 @@ static bool sync_read_text(
static TrainlogStatus sync_run_python_tool( static TrainlogStatus sync_run_python_tool(
const char *tool_name, const char *tool_name,
const char *argument, const char *argument,
const char *database_path,
const char *result_path, const char *result_path,
char *output, char *output,
size_t output_size size_t output_size
@ -1028,6 +1047,20 @@ static TrainlogStatus sync_run_python_tool(
result_fd result_fd
); );
if (database_path != NULL) {
/* CONTRACT: helpers which mutate the desktop store receive its
* explicit XDG-resolved path. They must never infer a different
* user's database from Python's process environment. */
execlp(
"python3",
"python3",
tool,
argument,
"--database",
database_path,
(char *)NULL
);
} else {
execlp( execlp(
"python3", "python3",
"python3", "python3",
@ -1035,6 +1068,7 @@ static TrainlogStatus sync_run_python_tool(
argument, argument,
(char *)NULL (char *)NULL
); );
}
_exit(127); _exit(127);
} }
@ -2192,6 +2226,10 @@ TrainlogStatus trainlog_sync_run(
SYNC_REQUEST_TEXT_MAX + 1U SYNC_REQUEST_TEXT_MAX + 1U
]; ];
char database_path[
PATH_MAX + 1U
];
uint32_t folder_id = 0U; uint32_t folder_id = 0U;
uint64_t ignored_size = 0U; uint64_t ignored_size = 0U;
int lock_fd = -1; int lock_fd = -1;
@ -2400,6 +2438,16 @@ TrainlogStatus trainlog_sync_run(
run_started = true; run_started = true;
/* INVARIANT: every local import/export in one run addresses the same
* XDG-resolved database as the TUI, never a Python-derived fallback. */
if (!sync_data_file("trainlog.db", database_path,
sizeof(database_path))) {
(void)snprintf(output->error, sizeof(output->error),
"Synchronisation : chemin de base introuvable.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = status =
sync_receive_named( sync_receive_named(
&device, &device,
@ -2413,6 +2461,12 @@ TrainlogStatus trainlog_sync_run(
status != status !=
TRAINLOG_STATUS_OK TRAINLOG_STATUS_OK
) { ) {
/* Explicit historic fallback only: a V1 file is never mistaken for
* V2, and V2 remains the default path for all current Android apps. */
status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME,
MOBILE_EXPORT_LOCAL, &ignored_size);
}
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf( (void)snprintf(
output->error, output->error,
sizeof(output->error), sizeof(output->error),
@ -2428,6 +2482,7 @@ TrainlogStatus trainlog_sync_run(
sync_run_python_tool( sync_run_python_tool(
"import_mobile_export.py", "import_mobile_export.py",
MOBILE_EXPORT_LOCAL, MOBILE_EXPORT_LOCAL,
database_path,
MOBILE_IMPORT_RESULT, MOBILE_IMPORT_RESULT,
tool_output, tool_output,
sizeof(tool_output) sizeof(tool_output)
@ -2470,10 +2525,43 @@ TrainlogStatus trainlog_sync_run(
output output
); );
/* V1 exports carry no equipment signal. A missing companion therefore
* preserves existing desktop associations rather than clearing them. */
status = sync_receive_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_NAME,
EQUIPMENT_ASSOCIATIONS_LOCAL, &ignored_size);
if (status == TRAINLOG_STATUS_OK) {
char useful[
TRAINLOG_SYNC_ERROR_MAX + 1U
];
status = sync_run_python_tool("import_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS") == NULL) {
sync_last_nonempty_line(tool_output, useful, sizeof(useful));
(void)snprintf(output->error, sizeof(output->error),
"Android→PC : import équipement : %s",
useful[0] != '\0'
? useful
: "échec sans diagnostic du script");
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 extension équipement échouée.");
final_status = status;
goto finalize;
}
status = status =
sync_run_python_tool( sync_run_python_tool(
"export_pc_catalog.py", "export_pc_catalog.py",
PC_CATALOG_LOCAL, PC_CATALOG_LOCAL,
database_path,
PC_CATALOG_RESULT, PC_CATALOG_RESULT,
tool_output, tool_output,
sizeof(tool_output) sizeof(tool_output)
@ -2517,6 +2605,21 @@ TrainlogStatus trainlog_sync_run(
"exercises" "exercises"
); );
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
database_path,
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V2 échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V2 échouée.");
final_status = status;
goto finalize;
}
status = status =
sync_publish_named( sync_publish_named(
&device, &device,
@ -2540,6 +2643,28 @@ TrainlogStatus trainlog_sync_run(
goto finalize; goto finalize;
} }
status = sync_run_python_tool("export_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||
strstr(tool_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS") == NULL) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : export équipement échoué.");
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finalize;
}
status = sync_publish_named(&device, folder_id, EQUIPMENT_ASSOCIATIONS_LOCAL,
EQUIPMENT_ASSOCIATIONS_NAME);
if (status != TRAINLOG_STATUS_OK) {
(void)snprintf(output->error, sizeof(output->error),
"PC→Android : publication équipement échouée.");
final_status = status;
goto finalize;
}
output->success = true; output->success = true;
final_status = final_status =
TRAINLOG_STATUS_OK; TRAINLOG_STATUS_OK;

View file

@ -7172,89 +7172,9 @@ static void screen_session_detail(
static void history_ascii_header(void) static void history_ascii_header(void)
{ {
static const char *const logo[] = { /* CONTRACT: history shares the current page shell; the old local logo
"TTTTT RRRR AAA IIIII N N L OOO GGG ", * occupied six unrelated rows and made this screen an exception. */
" T R R A A I NN N L O O G ", section_ascii_header("Historique");
" T RRRR AAAAA I N N N L O O G GG",
" T R R A A I N NN L O O G G",
" T R R A A IIIII N N LLLLL OOO GGG "
};
const size_t line_count =
sizeof(logo) /
sizeof(logo[0]);
size_t index;
trainlog_terminal_style_on(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(
TRAINLOG_COLOR_ACCENT
)
);
for (index = 0U;
index < line_count;
++index) {
int width =
(int)strlen(logo[index]);
int column =
(trainlog_terminal_columns(tui_terminal) - width) / 2;
if (column < 2) {
column = 2;
}
trainlog_terminal_printf(tui_terminal,
1 + (int)index,
column,
"%.*s",
trainlog_terminal_columns(tui_terminal) - column - 2,
logo[index]
);
}
trainlog_terminal_style_off(tui_terminal,
TRAINLOG_TEXT_BOLD |
trainlog_theme_style(
TRAINLOG_COLOR_ACCENT
)
);
trainlog_terminal_style_on(tui_terminal,
trainlog_theme_style(
TRAINLOG_COLOR_MUTED
)
);
{
const char *label =
":: H I S T O R I Q U E ::";
int width =
(int)strlen(label);
int column =
(trainlog_terminal_columns(tui_terminal) - width) / 2;
if (column < 2) {
column = 2;
}
trainlog_terminal_printf(tui_terminal,
6,
column,
"%s",
label
);
}
trainlog_terminal_style_off(tui_terminal,
trainlog_theme_style(
TRAINLOG_COLOR_MUTED
)
);
} }
static void history_scrollbar( static void history_scrollbar(

View file

@ -49,6 +49,27 @@ static bool test_database_open_and_schema(void)
return true; return true;
} }
static bool test_database_open_diagnostic(void)
{
TrainlogDatabase *database = NULL;
char diagnostic[256];
/* CONTRACT: a launcher must retain the stable status code while showing
* the SQLite operation that blocked access to the user's real database. */
CHECK(
trainlog_database_open_with_diagnostic(
"/",
&database,
diagnostic,
sizeof(diagnostic)
) == TRAINLOG_STATUS_DATABASE_ERROR
);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "open database:") != NULL);
return true;
}
static bool test_generated_ids(void) static bool test_generated_ids(void)
{ {
char first[TRAINLOG_GENERATED_ID_CAPACITY]; char first[TRAINLOG_GENERATED_ID_CAPACITY];
@ -262,6 +283,7 @@ int main(void)
{ {
static const struct TestCase tests[] = { static const struct TestCase tests[] = {
{"database_open_and_schema", test_database_open_and_schema}, {"database_open_and_schema", test_database_open_and_schema},
{"database_open_diagnostic", test_database_open_diagnostic},
{"generated_ids", test_generated_ids}, {"generated_ids", test_generated_ids},
{"session_insert", test_session_insert}, {"session_insert", test_session_insert},
{"body_weight_history", test_body_weight_history}, {"body_weight_history", test_body_weight_history},

View file

@ -0,0 +1,24 @@
#include <assert.h>
#include <string.h>
#include "trainlog/equipment_catalog.h"
int main(void) {
const TrainlogEquipment *matches[8];
const TrainlogEquipment *machine;
size_t count;
assert(trainlog_equipment_catalog_count() == 38U);
machine = trainlog_equipment_catalog_lookup("assisted_dip_chin_machine");
assert(machine != NULL);
assert(strcmp(machine->load_semantics, "assistance") == 0);
count = trainlog_equipment_catalog_search("TIRAGE", matches, 8U);
assert(count >= 6U);
count = trainlog_equipment_catalog_search("ischio", matches, 8U);
assert(count >= 2U);
assert(trainlog_equipment_catalog_relation_count() == 2U);
assert(strcmp(trainlog_equipment_catalog_relation_at(0U)->equipment_id,
"assisted_dip_chin_machine") == 0);
assert(trainlog_equipment_catalog_lookup("missing") == NULL);
return 0;
}

View file

@ -1,6 +1,6 @@
/** /**
* @file test_schema_v5_migration.c * @file test_schema_v5_migration.c
* @brief Direct v4 -> v5 migration regression test. * @brief Direct v4 -> v7 migration regression test.
*/ */
#include <stdbool.h> #include <stdbool.h>
@ -27,7 +27,7 @@
} \ } \
} while (0) } while (0)
static bool test_v4_to_v5_preserves_session(void) static bool test_v4_to_v7_preserves_session(void)
{ {
char path[] = char path[] =
"/tmp/trainlog-schema-v4-v5-XXXXXX"; "/tmp/trainlog-schema-v4-v5-XXXXXX";
@ -188,7 +188,7 @@ static bool test_v4_to_v5_preserves_session(void)
); );
CHECK( CHECK(
version == 5 version == 7
); );
CHECK( CHECK(
@ -228,7 +228,7 @@ static bool test_v4_to_v5_preserves_session(void)
int main(void) int main(void)
{ {
CHECK( CHECK(
test_v4_to_v5_preserves_session() test_v4_to_v7_preserves_session()
); );
(void)printf( (void)printf(

View file

@ -74,6 +74,8 @@ static bool test_session_details(void)
"%s", "%s",
"ex_detail" "ex_detail"
); );
(void)snprintf(exercise.equipment_id, sizeof(exercise.equipment_id),
"%s", "leg_press");
exercise.load_mode = exercise.load_mode =
TRAINLOG_LOAD_EXTERNAL; TRAINLOG_LOAD_EXTERNAL;
@ -144,6 +146,7 @@ static bool test_session_details(void)
CHECK(details[0].target_sets == 3); CHECK(details[0].target_sets == 3);
CHECK(details[0].target_reps == 5); CHECK(details[0].target_reps == 5);
CHECK(details[0].rest_seconds == 60); CHECK(details[0].rest_seconds == 60);
CHECK(strcmp(details[0].equipment_id, "leg_press") == 0);
CHECK(details[0].actual_set_count == 3U); CHECK(details[0].actual_set_count == 3U);
CHECK( CHECK(
strcmp( strcmp(