From 7093dd5606cf5cef29cd98828f577794ff7ba439 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 7 Sep 2026 23:05:10 +0200 Subject: [PATCH] fix(tui): use shared header on history screen --- CHANGELOG.md | 29 +- README.md | 34 +- android/app/build.gradle.kts | 8 + .../trainlog/data/EquipmentCatalog.kt | 116 ++++ .../trainlog/data/SyncCatalogInbox.kt | 51 +- .../labfytools/trainlog/data/SyncExporter.kt | 40 +- .../trainlog/data/TrainlogRepository.kt | 625 +++++++++++++++++- .../trainlog/model/SessionModels.kt | 14 + .../trainlog/ui/SessionDetailScreen.kt | 56 +- .../labfytools/trainlog/ui/SessionScreen.kt | 214 +++++- .../trainlog/data/EquipmentCatalogTest.kt | 38 ++ .../data/TrainlogRepositoryDraftTest.kt | 148 ++++- catalog/equipment-v1.json | 48 ++ docs/android.md | 68 +- docs/architecture.md | 6 +- docs/current_state.md | 37 +- docs/database.md | 34 +- docs/roadmap.md | 6 +- docs/sync_exchange.md | 48 +- docs/tests.md | 60 +- tests/test_equipment_associations_exchange.py | 41 ++ tests/test_mobile_import_multi_occurrence.py | 127 ++++ tools/export_equipment_associations.py | 45 ++ tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 66 ++ tools/generate_equipment_catalog.py | 72 ++ tools/import_equipment_associations.py | 80 +++ tools/import_mobile_export.py | 162 +++-- tools/validate_json.py | 52 +- tui/include/trainlog/database.h | 23 +- tui/include/trainlog/equipment_catalog.h | 31 + tui/include/trainlog/model.h | 4 + tui/meson.build | 28 + tui/src/database.c | 297 +++++++-- tui/src/main.c | 16 +- tui/src/sync.c | 141 +++- tui/src/tui.c | 86 +-- tui/tests/test_database.c | 22 + tui/tests/test_equipment_catalog.c | 24 + tui/tests/test_schema_v5_migration.c | 8 +- tui/tests/test_session_detail.c | 3 + 41 files changed, 2689 insertions(+), 321 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/EquipmentCatalogTest.kt create mode 100644 catalog/equipment-v1.json create mode 100644 tests/test_equipment_associations_exchange.py create mode 100644 tests/test_mobile_import_multi_occurrence.py create mode 100644 tools/export_equipment_associations.py create mode 100644 tools/export_pc_mobile.py create mode 100644 tools/generate_equipment_catalog.py create mode 100644 tools/import_equipment_associations.py create mode 100644 tui/include/trainlog/equipment_catalog.h create mode 100644 tui/tests/test_equipment_catalog.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f97f04..de239a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Detailed implementation chronology remains available in Git history and ### 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, explicit invalid/conflict/profile/database results, and profile locking once completed history or an active draft references the exercise; @@ -45,6 +52,14 @@ Detailed implementation chronology remains available in Git history and ### 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 in place and rejects a different-ID normalized-name collision, preserving synchronization identity and preventing renamed duplicates; @@ -68,6 +83,12 @@ Detailed implementation chronology remains available in Git history and ### 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 the Activity/process; - missing selected-exercise recovery preserves raw partial input and reports a @@ -88,12 +109,12 @@ Current validated baseline: ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V5=PASS -DESKTOP_TESTS=22/22 PASS +DESKTOP_SCHEMA_V7=PASS +DESKTOP_TESTS=25/25 PASS ANDROID_BUILD=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V4=PASS +ANDROID_LOCAL_DATABASE_V7=PASS ANDROID_HOST_TESTS=8/8 PASS ANDROID_DEVICE_INSTRUMENTATION=5/5 PASS ANDROID_SESSION_DRAFT_V1=PASS @@ -109,6 +130,8 @@ ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS +MULTI_OCCURRENCE_SESSION_V2=PASS +EQUIPMENT_ASSOCIATIONS_V2=PASS ``` ### Measured max v1 diff --git a/README.md b/README.md index 15ac034..c045953 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ desktop. ```text TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V5=PASS +DESKTOP_SCHEMA_V7=PASS ANDROID_LOCAL_WORKFLOWS=PASS -ANDROID_LOCAL_DATABASE_V4=PASS +ANDROID_LOCAL_DATABASE_V7=PASS ANDROID_SESSION_DRAFT_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS @@ -32,8 +32,10 @@ TRAINLOG_SYNCD=PASS ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=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 ``` @@ -100,6 +102,13 @@ Actual repetition sets are stored independently. Compact input supports: 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 ```text @@ -138,6 +147,10 @@ export or desktop synchronization as completed sessions. Schema v4 migrates additively from v3, preserving existing capture data. See [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_` identity is unchanged; completed history, an active draft, and synchronization therefore 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 +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: ```bash @@ -181,6 +204,9 @@ Sync 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. +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 @@ -246,5 +272,5 @@ BODY_ANALYTICS_V1=PASS BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS -DESKTOP_TESTS=22/22 PASS +DESKTOP_TESTS=25/25 PASS ``` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 77ee765..99b4651 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -27,6 +27,14 @@ android { 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 { unitTests.isIncludeAndroidResources = true unitTests.all { diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt b/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt new file mode 100644 index 0000000..e5de256 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/EquipmentCatalog.kt @@ -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, + 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 { + 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() + 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 { + 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, + query: String, + ): List { + 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() +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index 0ddab64..8f71e97 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -137,14 +137,17 @@ class SyncCatalogInbox( ) ) { is PcCatalogImportResult.Applied -> - CatalogInboxResult.Imported( - imported = - result.imported, - reconciled = - result.reconciled, - skipped = - result.skipped, - ) + when (val sessions = importPcSessions(directory)) { + null -> when (val equipment = importPcEquipmentAssociations(directory)) { + null -> CatalogInboxResult.Imported( + imported = result.imported, + reconciled = result.reconciled, + skipped = result.skipped, + ) + else -> CatalogInboxResult.Error(equipment) + } + else -> CatalogInboxResult.Error(sessions) + } is PcCatalogImportResult.Invalid -> 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( requestId: String, ): SyncReceiptResult { diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index 685b1da..9d5ac4d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -39,8 +39,9 @@ class SyncExporter( return SyncExportResult.Unsupported } - val json = - repository.buildMobileExportJson() + /* V2 is the authoritative mobile session exchange. V1 remains + * readable by desktop for historic devices but is not published here. */ + val json = repository.buildMobileExportV2Json() val bytes = json.toByteArray( @@ -62,7 +63,7 @@ class SyncExporter( "/Trainlog/" val displayName = - "trainlog-mobile-export-v1.json" + "trainlog-mobile-export-v2.json" val existing = findExisting( @@ -170,6 +171,10 @@ class SyncExporter( ) } + val companionError = writeEquipmentAssociations() + if (companionError != null) { + return SyncExportResult.Error(companionError) + } return SyncExportResult.Exported( displayPath = "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( collection: Uri, displayName: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index bef6f81..b25fb2c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -67,6 +67,25 @@ sealed interface 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 { data class Saved( val observationId: String, @@ -131,9 +150,10 @@ class TrainlogRepository( databaseName: String = ANDROID_DATABASE_NAME, ) { + private val applicationContext = context.applicationContext private val database = TrainlogDatabaseHelper( - context.applicationContext, + applicationContext, databaseName, ) @@ -141,6 +161,61 @@ class TrainlogRepository( database.close() } + fun listEquipment(): List { + val output = mutableListOf() + 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() + 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 = + 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 { val output = mutableListOf() @@ -439,9 +514,16 @@ class TrainlogRepository( draft.exercises.any { !validateSessionExercise(it) } || + /* exercise_id identifies the catalogue movement. Repeated passages + * are valid; only their stable occurrence IDs must be unique. */ draft.exercises .map { - it.exercise.exerciseId + it.entryId + } + .any { it.isBlank() } || + draft.exercises + .map { + it.entryId } .distinct() .size != @@ -691,6 +773,15 @@ class TrainlogRepository( 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 = @@ -757,6 +848,7 @@ class TrainlogRepository( "position", setIndex ) + set.weightKg?.let { put("weight_kg", it) } if ( exerciseDraft.exercise @@ -1198,6 +1290,26 @@ class TrainlogRepository( 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( db: SQLiteDatabase, exerciseRowId: Long, @@ -1350,6 +1462,216 @@ class TrainlogRepository( 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() + 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( draft: BodyObservationDraft, ): SaveBodyObservationResult { @@ -1587,15 +1909,20 @@ class TrainlogRepository( """ SELECT se.id, + se.entry_id, + e.exercise_id, e.name, se.recording_mode, se.tracking_mode, - se.data_fields + se.data_fields, + eq.display_name FROM session_exercises AS se JOIN sessions AS s ON s.id = se.session_row_id JOIN exercises AS e ON e.id = se.exercise_row_id + LEFT JOIN equipment AS eq + ON eq.id = se.equipment_row_id WHERE s.session_id = ? ORDER BY se.position ASC; """.trimIndent(), @@ -1605,12 +1932,13 @@ class TrainlogRepository( val sessionExerciseRowId = cursor.getLong(0) - val name = - cursor.getString(1) + val entryId = cursor.getString(1) + val exerciseId = cursor.getString(2) + val name = cursor.getString(3) val recording = when ( - cursor.getString(2) + cursor.getString(4) ) { "continuous" -> RecordingMode.CONTINUOUS @@ -1621,7 +1949,7 @@ class TrainlogRepository( val tracking = when ( - cursor.getString(3) + cursor.getString(5) ) { "duration" -> TrackingMode.DURATION @@ -1631,7 +1959,8 @@ class TrainlogRepository( } val dataFields = - cursor.getInt(4) + cursor.getInt(6) + val equipmentDisplayName = if (cursor.isNull(7)) null else cursor.getString(7) if ( recording == @@ -1666,8 +1995,11 @@ class TrainlogRepository( exercises += SessionExerciseDetail( + entryId = entryId, + exerciseId = exerciseId, exerciseName = name, + equipmentDisplayName = equipmentDisplayName, recordingMode = recording, trackingMode = @@ -1710,6 +2042,7 @@ class TrainlogRepository( arrayOf( "reps", "duration_seconds", + "weight_kg", ), "session_exercise_row_id = ?", arrayOf( @@ -1746,14 +2079,18 @@ class TrainlogRepository( setCursor .getInt(1) }, + weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2), ) } } exercises += SessionExerciseDetail( + entryId = entryId, + exerciseId = exerciseId, exerciseName = name, + equipmentDisplayName = equipmentDisplayName, recordingMode = recording, 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( db: SQLiteDatabase, ): ActiveDraftRestore? { @@ -1787,6 +2144,7 @@ class TrainlogRepository( d.distance_text, d.updated_at, d.selected_exercise_label, + d.selected_equipment_id, e.exercise_id, e.name, e.normalized_name, @@ -1804,15 +2162,15 @@ class TrainlogRepository( null } else { val missingSelection = - cursor.isNull(8) && + cursor.isNull(9) && !cursor.isNull(7) val selected = - if (cursor.isNull(8)) { + if (cursor.isNull(9)) { null } else { exerciseProfileFromCursor( cursor, - 8, + 9, ) } @@ -1823,6 +2181,7 @@ class TrainlogRepository( ), form = SessionDraftForm( selectedExercise = selected, + selectedEquipmentId = if (cursor.isNull(8)) null else cursor.getString(8), setCountText = cursor.getString(1), repsText = cursor.getString(2), durationText = cursor.getString(3), @@ -1856,10 +2215,14 @@ class TrainlogRepository( e.normalized_name, de.recording_mode, de.tracking_mode, - de.data_fields + de.data_fields, + eq.equipment_id, + de.entry_id FROM draft_session_exercises AS de JOIN exercises AS e ON e.id = de.exercise_row_id + LEFT JOIN equipment AS eq + ON eq.id = de.equipment_row_id WHERE de.draft_id = ? ORDER BY de.position ASC; """.trimIndent(), @@ -1882,9 +2245,11 @@ class TrainlogRepository( trackingModeFromWire( cursor.getString(5) ), - dataFields = - cursor.getInt(6), + dataFields = + cursor.getInt(6), ) + val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7) + val entryId = cursor.getString(8) if ( exercise.recordingMode == @@ -1909,7 +2274,9 @@ class TrainlogRepository( } SessionExerciseDraft( + entryId = entryId, exercise = exercise, + equipmentId = equipmentId, continuousDurationSeconds = item.getInt(0), speedKmh = @@ -1935,6 +2302,7 @@ class TrainlogRepository( arrayOf( "reps", "duration_seconds", + "weight_kg", ), "draft_exercise_row_id = ?", arrayOf(rowId.toString()), @@ -1957,12 +2325,15 @@ class TrainlogRepository( } else { setCursor.getInt(1) }, + weightKg = if (setCursor.isNull(2)) null else setCursor.getDouble(2), ) } } exercises += SessionExerciseDraft( + entryId = entryId, exercise = exercise, + equipmentId = equipmentId, sets = sets, ) } @@ -1996,6 +2367,7 @@ class TrainlogRepository( val values = ContentValues().apply { put("session_type", draft.sessionType.wireValue) + putOptionalString("selected_equipment_id", draft.form.selectedEquipmentId) if (selectedRowId == null) { putNull("selected_exercise_row_id") putNull("selected_exercise_label") @@ -2064,6 +2436,15 @@ class TrainlogRepository( "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 draftExerciseRowId = db.insertOrThrow( @@ -2109,6 +2490,7 @@ class TrainlogRepository( draftExerciseRowId, ) put("position", setIndex) + set.weightKg?.let { put("weight_kg", it) } if ( exerciseDraft.exercise.trackingMode == TrackingMode.REPS @@ -2175,6 +2557,15 @@ class TrainlogRepository( exerciseDraft.exercise.trackingMode.wireValue, ) 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 = db.insertOrThrow( @@ -2210,6 +2601,7 @@ class TrainlogRepository( ContentValues().apply { put("session_exercise_row_id", sessionExerciseRowId) put("position", setIndex) + set.weightKg?.let { put("weight_kg", it) } if ( exerciseDraft.exercise.trackingMode == 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 = "trainlog-android.db" private const val ACTIVE_DRAFT_ID = 1 private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 private class TrainlogDatabaseHelper( - context: Context, + private val appContext: Context, databaseName: String, ) : SQLiteOpenHelper( - context, + appContext, databaseName, null, - 4, + 7, ) { override fun onConfigure( db: SQLiteDatabase, @@ -2438,6 +2843,8 @@ private class TrainlogDatabaseHelper( createSessionTables(db) createBodyTable(db) createActiveDraftTables(db) + createEquipmentTables(db) + seedEquipment(db) } override fun onUpgrade( @@ -2464,6 +2871,41 @@ private class TrainlogDatabaseHelper( 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) { error( "Unsupported Android DB upgrade " + @@ -2569,6 +3011,10 @@ private class TrainlogDatabaseHelper( data_fields >= 0 AND (data_fields & ~3) = 0 ), + equipment_row_id INTEGER + REFERENCES equipment(id) + ON DELETE SET NULL, + entry_id TEXT NOT NULL UNIQUE, UNIQUE( session_row_id, position @@ -2590,6 +3036,8 @@ private class TrainlogDatabaseHelper( CHECK(reps >= 0), duration_seconds INTEGER CHECK(duration_seconds > 0), + weight_kg REAL + CHECK(weight_kg >= 0.0), CHECK( ( reps IS NOT NULL AND @@ -2705,6 +3153,7 @@ private class TrainlogDatabaseHelper( REFERENCES exercises(id) ON DELETE SET NULL, selected_exercise_label TEXT, + selected_equipment_id TEXT, set_count_text TEXT NOT NULL, reps_text TEXT NOT NULL, duration_text TEXT NOT NULL, @@ -2746,8 +3195,11 @@ private class TrainlogDatabaseHelper( data_fields >= 0 AND (data_fields & ~3) = 0 ), - UNIQUE(draft_id, position), - UNIQUE(draft_id, exercise_row_id) + equipment_row_id INTEGER + REFERENCES equipment(id) + ON DELETE SET NULL, + entry_id TEXT NOT NULL UNIQUE, + UNIQUE(draft_id, position) ); """.trimIndent() ) @@ -2768,6 +3220,8 @@ private class TrainlogDatabaseHelper( CHECK(reps >= 0), duration_seconds INTEGER CHECK(duration_seconds > 0), + weight_kg REAL + CHECK(weight_kg >= 0.0), CHECK( ( reps IS NOT NULL AND @@ -2799,4 +3253,137 @@ private class TrainlogDatabaseHelper( """.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( + 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( + relation.exerciseId, + cursor.getLong(0), + relation.loadSemantics.name.lowercase(Locale.ROOT), + ), + ) + } + } + } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 8ba99a5..7f223c0 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -21,10 +21,15 @@ enum class SessionType( data class SessionSetDraft( val reps: Int = 0, val durationSeconds: Int = 0, + val weightKg: Double? = null, ) 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, + /** Stable canonical equipment ID selected for this occurrence, if any. */ + val equipmentId: String? = null, val sets: List = emptyList(), val continuousDurationSeconds: Int = 0, val speedKmh: Double? = null, @@ -38,8 +43,13 @@ data class SessionDraft( data class SessionDraftForm( 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 repsText: String = "3x10", + val weightText: String = "", val durationText: String = "30", val speedText: String = "", val distanceText: String = "", @@ -65,7 +75,11 @@ data class SessionSummary( ) data class SessionExerciseDetail( + /** Stable completed-session occurrence identity, never catalogue identity. */ + val entryId: String, + val exerciseId: String, val exerciseName: String, + val equipmentDisplayName: String? = null, val recordingMode: RecordingMode, val trackingMode: TrackingMode, val dataFields: Int, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 8ae7a26..141ef93 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -2,6 +2,9 @@ package com.labfytools.trainlog.ui import androidx.compose.runtime.Composable 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.model.ExerciseDataFields import com.labfytools.trainlog.model.RecordingMode @@ -19,8 +22,12 @@ fun SessionDetailScreen( val colors = LocalTrainlogColors.current + var revision by remember(sessionId) { mutableStateOf(0) } + var editingEntryId by remember(sessionId) { mutableStateOf(null) } + var equipmentQuery by remember(sessionId) { mutableStateOf("") } + val detail = - remember(sessionId) { + remember(sessionId, revision) { sessionId?.let { repository.getSessionDetail( it @@ -97,6 +104,45 @@ fun SessionDetailScreen( title = "${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 ( exercise.recordingMode == RecordingMode.CONTINUOUS @@ -144,7 +190,13 @@ private fun SetsDetail( exercise.trackingMode == 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 { "Série ${index + 1} : ${formatDuration(set.durationSeconds)}" } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index f686644..5589d95 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.labfytools.trainlog.data.ActiveDraftLoadResult 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.TrainlogRepository import com.labfytools.trainlog.model.ActiveSessionDraft @@ -275,6 +277,20 @@ fun SessionScreen( 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( label = "Retirer ${draft.exercise.name}", @@ -330,12 +346,9 @@ fun SessionScreen( .selectedExercise ?.exerciseId == exercise.exerciseId, - disabled = - alreadyAdded, + disabled = false, onClick = { - if ( - !alreadyAdded - ) { + if (true) { persistDraft( currentDraft.copy( form = @@ -360,6 +373,7 @@ fun SessionScreen( SessionExerciseForm( key = editingExercise.exerciseId, + repository = repository, exercise = editingExercise, initialForm = @@ -368,7 +382,10 @@ fun SessionScreen( form -> persistDraft( currentDraft.copy( - form = form + form = form.copy( + editingExerciseIndex = currentDraft.form.editingExerciseIndex, + editingEntryId = currentDraft.form.editingEntryId, + ) ), null, ) @@ -384,15 +401,18 @@ fun SessionScreen( }, onAdd = { draft -> + val editIndex = currentDraft.form.editingExerciseIndex persistDraft( currentDraft.copy( - exercises = - currentDraft.exercises + - draft, + exercises = editIndex?.let { replacingIndex -> + currentDraft.exercises.mapIndexed { index, existing -> + if (index == replacingIndex) draft else existing + } + } ?: (currentDraft.exercises + draft), form = 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 private fun SessionExerciseForm( key: String, + repository: TrainlogRepository, exercise: ExerciseProfile, initialForm: SessionDraftForm, onFormChanged: (SessionDraftForm) -> Unit, @@ -619,6 +640,13 @@ private fun SessionExerciseForm( ) } + var weightText by + remember(key) { + mutableStateOf( + initialForm.weightText + ) + } + var durationText by remember(key) { 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 remember(key) { mutableStateOf< @@ -659,6 +695,58 @@ private fun SessionExerciseForm( 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 ( exercise.recordingMode == RecordingMode.SETS @@ -683,6 +771,8 @@ private fun SessionExerciseForm( durationText, speedText, distanceText, + selectedEquipmentId, + weightText, ) ) }, @@ -694,6 +784,29 @@ private fun SessionExerciseForm( color = 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 { SessionNumberField( label = @@ -711,6 +824,8 @@ private fun SessionExerciseForm( durationText, speedText, distanceText, + selectedEquipmentId, + weightText, ) ) }, @@ -732,6 +847,8 @@ private fun SessionExerciseForm( it, speedText, distanceText, + selectedEquipmentId, + weightText, ) ) }, @@ -752,8 +869,10 @@ private fun SessionExerciseForm( setCountText, repsText, it, - speedText, - distanceText, + speedText, + distanceText, + selectedEquipmentId, + weightText, ) ) }, @@ -780,6 +899,8 @@ private fun SessionExerciseForm( durationText, it, distanceText, + selectedEquipmentId, + weightText, ) ) }, @@ -807,6 +928,8 @@ private fun SessionExerciseForm( durationText, speedText, it, + selectedEquipmentId, + weightText, ) ) }, @@ -836,6 +959,9 @@ private fun SessionExerciseForm( speedText, distanceText = distanceText, + equipmentId = selectedEquipmentId, + weightText = weightText, + entryId = initialForm.editingEntryId, ) if (draft == null) { @@ -877,11 +1003,15 @@ private fun currentForm( durationText: String, speedText: String, distanceText: String, + equipmentId: String? = null, + weightText: String = "", ): SessionDraftForm = SessionDraftForm( selectedExercise = exercise, + selectedEquipmentId = equipmentId, setCountText = setCountText, repsText = repsText, + weightText = weightText, durationText = durationText, speedText = speedText, distanceText = distanceText, @@ -1062,6 +1192,9 @@ private fun buildSessionExerciseDraft( durationText: String, speedText: String, distanceText: String, + equipmentId: String? = null, + weightText: String = "", + entryId: String? = null, ): SessionExerciseDraft? { return if ( exercise.recordingMode == @@ -1122,7 +1255,9 @@ private fun buildSessionExerciseDraft( null } else { SessionExerciseDraft( + entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(), exercise = exercise, + equipmentId = equipmentId, continuousDurationSeconds = minutes * 60, speedKmh = speed, @@ -1139,12 +1274,17 @@ private fun buildSessionExerciseDraft( repsText ) ?: return null + val weights = parseWeightSequence(weightText, reps.size) ?: return null + SessionExerciseDraft( + entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(), exercise = exercise, + equipmentId = equipmentId, sets = - reps.map { + reps.mapIndexed { index, rep -> SessionSetDraft( - reps = it + reps = rep, + weightKg = weights[index], ) }, ) @@ -1166,7 +1306,9 @@ private fun buildSessionExerciseDraft( null } else { SessionExerciseDraft( + entryId = entryId ?: "sxe_" + java.util.UUID.randomUUID().toString(), exercise = exercise, + equipmentId = equipmentId, sets = List(count) { 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? { + 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 + return when { + parsed.size == 1 -> List(count) { parsed.single() } + parsed.size == count -> parsed + else -> null + } +} + private fun draftSummary( draft: SessionExerciseDraft, ): String { diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/EquipmentCatalogTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/EquipmentCatalogTest.kt new file mode 100644 index 0000000..8d66cdd --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/EquipmentCatalogTest.kt @@ -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() + + @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 }) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 462f2f4..451f72f 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -63,6 +63,7 @@ class TrainlogRepositoryDraftTest { exercises = listOf( SessionExerciseDraft( exercise = reps, + equipmentId = "leg_press", sets = listOf(4, 5, 6, 7).map { SessionSetDraft(reps = it) }, ), SessionExerciseDraft( @@ -79,6 +80,7 @@ class TrainlogRepositoryDraftTest { sessionType = SessionType.MAX_TEST, form = SessionDraftForm( selectedExercise = reps, + selectedEquipmentId = "treadmill", setCountText = "4", repsText = "4,5,6,", durationText = "31", @@ -161,6 +163,68 @@ class TrainlogRepositoryDraftTest { 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 fun finalizationFailureRollsBackCompletedRowsAndKeepsDraft() { val repo = openRepository() @@ -452,14 +516,55 @@ class TrainlogRepositoryDraftTest { } @Test - fun versionThreeMigrationPreservesCompletedAndBodyData() { - createVersionThreeFixture(context.getDatabasePath(databaseName).path) + fun completedSessionKeepsEquipmentAcrossReopenAndCompanionExport() { + 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() assertEquals(1, repo.listExercises().size) assertEquals(1, repo.listSessions().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( context.getDatabasePath(databaseName).path, null, @@ -467,7 +572,11 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> 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 -> assertFalse(cursor.moveToFirst()) @@ -499,7 +608,8 @@ class TrainlogRepositoryDraftTest { 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 -> db.execSQL( "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) " + "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;") } } } diff --git a/catalog/equipment-v1.json b/catalog/equipment-v1.json new file mode 100644 index 0000000..dfc3df5 --- /dev/null +++ b/catalog/equipment-v1.json @@ -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"} + ] +} diff --git a/docs/android.md b/docs/android.md index 52118e6..ab448f3 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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. +## 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 ```text @@ -25,7 +33,7 @@ Accueil Android local database version: ```text -4 +7 ``` Domain tables cover: @@ -41,9 +49,11 @@ body_observations This database is Android-local. It is not copied to the PC. -Schema v4 adds `active_session_draft`, `draft_session_exercises`, -`draft_performed_sets` and `draft_continuous_activity`. The additive v3 -> v4 -migration preserves catalog, completed sessions/actuals and body observations. +Schema v4 introduced `active_session_draft`, `draft_session_exercises`, +`draft_performed_sets` and `draft_continuous_activity`. The implemented +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. ## 4. Exercise catalog @@ -120,6 +130,21 @@ speed or distance. 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 The repository durably saves every meaningful mutation, including session type, @@ -196,11 +221,15 @@ bo_ Android maintains: ```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, -session, body-observation, and PC-catalog updates. +The V2 snapshot is refreshed after relevant local changes, including exercise, +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. @@ -306,6 +335,31 @@ Android is not intended to own: ## 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 `; saving replaces that +entry in place, while cancelling only discards the form and preserves it. + Android session entry exposes: ```text diff --git a/docs/architecture.md b/docs/architecture.md index 29cf76a..d3a55d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,7 +120,9 @@ Continuous work is persisted separately from performed sets. ### 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: @@ -135,7 +137,7 @@ body_observations ### 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 coupled to the desktop schema. diff --git a/docs/current_state.md b/docs/current_state.md index efc42bc..b61b072 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -14,8 +14,8 @@ GATE_2_PERSISTENCE_AND_USABLE_TUI=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V5=PASS -ANDROID_LOCAL_DATABASE_V4=PASS +DESKTOP_SCHEMA_V7=PASS +ANDROID_LOCAL_DATABASE_V7=PASS ANDROID_SESSION_DRAFT_V1=PASS ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS @@ -46,8 +46,10 @@ ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=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 HARDWARE_SYNC_VALIDATION=PASS ``` @@ -57,13 +59,16 @@ HARDWARE_SYNC_VALIDATION=PASS Implemented: - 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; - persisted session detail and editing; - exercise removal from a session through transactional child replacement; - exercise catalog; - profile-aware set and continuous activities; - 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 graphs and normalized overlays; - exercise performance history; @@ -87,7 +92,7 @@ Primary navigation: Implemented: - 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; - explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; @@ -97,6 +102,8 @@ Implemented: - heterogeneous repetition-set entry; - exercise removal from the current session draft; - continuous activity recording; +- shared equipment selection, local custom equipment creation and occurrence + equipment persistence; - local session history/detail; - body measurements; - automatic mobile snapshot maintenance; @@ -127,10 +134,13 @@ Artifacts: ```text Android -> PC - trainlog-mobile-export-v1.json + trainlog-mobile-export-v2.json + trainlog-equipment-associations-v2.json PC -> Android trainlog-pc-catalog-v1.json + trainlog-pc-mobile-export-v2.json + trainlog-equipment-associations-v2.json Android -> PC agent trainlog-sync-request-v1.json @@ -141,6 +151,10 @@ PC agent -> Android 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 mounted Android filesystem is required. @@ -150,7 +164,7 @@ No mounted Android filesystem is required. Desktop: ```text -22/22 Meson tests PASS +25/25 Meson tests PASS frozen JSON validator PASS import-contract validator PASS git diff --check PASS @@ -159,8 +173,9 @@ git diff --check PASS Android: ```text -assembleDebug PASS -host repository tests 8/8 PASS +assembleDebug and Android unit tests are run for every Android delivery. +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 real Samsung background/process-death/force-stop/resume matrix PASS real migration and original user-data preservation PASS @@ -191,7 +206,7 @@ MEASURED_MAX_ONLY_FROM_MAX_TEST=PASS WORKING_LOAD_PERCENTAGES=PASS ASSISTANCE_DIRECTION_AWARE=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 @@ -212,7 +227,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=22/22 PASS +DESKTOP_TESTS=25/25 PASS ``` Android remains capture-only for this feature. diff --git a/docs/database.md b/docs/database.md index e875c43..7faf9ab 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,8 +3,8 @@ ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=5 -DATABASE_SCHEMA_V5=PASS +TRAINLOG_DATABASE_SCHEMA_VERSION=7 +DATABASE_SCHEMA_V7=PASS TRAINLOG_FORMAT_V1=FROZEN ``` @@ -23,13 +23,19 @@ PRAGMA user_version; Current value: ```text -5 +7 ``` Supported historical databases are migrated explicitly through the implemented migration chain. A database newer than the running binary understands is 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 `user_version` is not an acceptable migration test. @@ -83,6 +89,7 @@ Ordered exercise occurrence inside one session. ```text session_row_id exercise_row_id +entry_id UNIQUE stable occurrence identity recording_mode data_fields position @@ -92,6 +99,7 @@ target_sets target_reps target_duration_seconds target_weight_kg +equipment_id nullable canonical equipment identity notes ``` @@ -296,11 +304,11 @@ Migration-specific regression coverage includes: schema_v5_migration ``` -The current normal desktop suite contains 22 tests. +The current normal desktop suite contains 25 tests. ## 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 `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 -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 by the user. diff --git a/docs/roadmap.md b/docs/roadmap.md index a50b2df..2c4076c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -14,8 +14,8 @@ GATE_1=PASS GATE_2=PASS TRAINLOG_FORMAT_V1=FROZEN -DESKTOP_SCHEMA_V5=PASS -ANDROID_LOCAL_DATABASE_V4=PASS +DESKTOP_SCHEMA_V7=PASS +ANDROID_LOCAL_DATABASE_V7=PASS DIRECT_MTP_TRANSPORT=PASS BIDIRECTIONAL_SYNC_V1=PASS @@ -26,7 +26,7 @@ BODY_ANALYTICS_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS -DESKTOP_TESTS=22/22 PASS +DESKTOP_TESTS=25/25 PASS TUI_NOTCURSES_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index be758ba..f69296f 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -12,6 +12,8 @@ ANDROID_TRIGGERED_SYNC=PASS ANDROID_SYNC_RECEIPT=PASS TUI_SYNC_LOG_SHOW=PASS BIDIRECTIONAL_SYNC_V1=PASS +MULTI_OCCURRENCE_SESSION_V2=PASS +EQUIPMENT_ASSOCIATIONS_V2=PASS TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED ``` @@ -37,12 +39,23 @@ Framework folder grant. | Direction | File | Format | | --- | --- | --- | | 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-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 | | PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 | 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 Header: @@ -50,7 +63,7 @@ Header: ```json { "format": "trainlog-mobile-export", - "version": 1 + "version": 2 } ``` @@ -62,6 +75,20 @@ sessions 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: ```text @@ -234,7 +261,10 @@ One synchronization transaction performs: ```text mobile snapshot download -> mobile import +-> equipment companion import by (session_id, entry_id) -> PC catalog export +-> PC mobile V2 export +-> PC equipment companion V2 export -> PC catalog MTP publication -> optional receipt publication -> structured run history @@ -315,6 +345,22 @@ overloading frozen Trainlog JSON v1 ## 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: ```text diff --git a/docs/tests.md b/docs/tests.md index 8e8fec2..cbc0f1f 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -63,32 +63,35 @@ Current normal suite: ```text 1 database 2 catalog - 3 session_detail - 4 duration - 5 body_metrics - 6 bodyviz - 7 exercise_performance - 8 session_type_schema - 9 session_edit -10 body_observation_edit -11 mtp -12 continuous_session -13 continuous_detail -14 reps + 3 equipment_catalog + 4 session_detail + 5 duration + 6 body_metrics + 7 bodyviz + 8 exercise_performance + 9 session_type_schema +10 session_edit +11 body_observation_edit +12 mtp +13 continuous_session +14 continuous_detail 15 exercise_profile_schema 16 usb -17 variable_sets -18 schema_v5_migration -19 measured_max -20 body_analytics -21 terminal_input_event_type_policy -22 mobile_import_variable_sets +17 reps +18 variable_sets +19 schema_v5_migration +20 measured_max +21 body_analytics +22 terminal_input_event_type_policy +23 mobile_import_multi_occurrence +24 equipment_associations_exchange +25 mobile_import_variable_sets ``` Validated checkpoint: ```text -22/22 PASS +25/25 PASS ``` The desktop executable is additionally smoke-checked in isolated tmux PTYs at @@ -104,12 +107,17 @@ Notable regression coverage: - profile-aware exercise constraints; - continuous activity without fake sets; - repetition shorthand/list/pyramid parsing; -- direct v4 -> v5 database migration; +- direct v4 -> v7 database migration; - heterogeneous mobile-set import; - Notcurses input lifecycle translation: PRESS/REPEAT are actionable while a RELEASE event is consumed without creating a second navigation action. - targetless mobile SETS persistence; - 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 rows or historical-reference replacement. @@ -258,7 +266,7 @@ Coverage proves: Current normal baseline: ```text -22/22 PASS +25/25 PASS ``` ## 12. Body analytics regression @@ -283,16 +291,18 @@ Coverage includes: Current normal baseline: ```text -22/22 PASS +25/25 PASS ``` ## 13. Android session draft v1 -Android schema v4 adds one durable active draft with an explicit additive v3 -> -v4 migration. The current host suite has **8 tests**, covering all exercise +Android schema v4 introduced one durable active draft; the current additive +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 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 cd android diff --git a/tests/test_equipment_associations_exchange.py b/tests/test_equipment_associations_exchange.py new file mode 100644 index 0000000..1c7169e --- /dev/null +++ b/tests/test_equipment_associations_exchange.py @@ -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() diff --git a/tests/test_mobile_import_multi_occurrence.py b/tests/test_mobile_import_multi_occurrence.py new file mode 100644 index 0000000..a1d3ef9 --- /dev/null +++ b/tests/test_mobile_import_multi_occurrence.py @@ -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() diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py new file mode 100644 index 0000000..a0021c5 --- /dev/null +++ b/tools/export_equipment_associations.py @@ -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) diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 1e5803a..9ebefee 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -61,7 +61,7 @@ def main(): "PRAGMA user_version;" ).fetchone()[0] - if version != 5: + if version != 7: raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py new file mode 100644 index 0000000..1e29ea1 --- /dev/null +++ b/tools/export_pc_mobile.py @@ -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) diff --git a/tools/generate_equipment_catalog.py b/tools/generate_equipment_catalog.py new file mode 100644 index 0000000..9c33616 --- /dev/null +++ b/tools/generate_equipment_catalog.py @@ -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 \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; } +''') diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py new file mode 100644 index 0000000..ab80339 --- /dev/null +++ b/tools/import_equipment_associations.py @@ -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) diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index cec6a6e..e3d8853 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -48,6 +48,10 @@ SESSION_EXERCISE_KEYS = { "continuous", } +V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | { + "entry_id", "position", "equipment_id" +} + BODY_BASE_KEYS = { "observation_id", "observed_at", @@ -209,7 +213,7 @@ def load_payload(path): "format mobile export invalide" ) - if payload["version"] != VERSION: + if payload["version"] not in (1, 2): raise ImportFailure( "version mobile export non supportée" ) @@ -322,10 +326,11 @@ def validate_set_item( tracking_mode, label, ): + allowed_weight = {"weight_kg"} if tracking_mode == "reps": require_exact_keys( value, - {"reps"}, + {"reps"} | allowed_weight, {"reps"}, label, ) @@ -341,7 +346,7 @@ def validate_set_item( require_exact_keys( value, - {"duration_seconds"}, + {"duration_seconds"} | allowed_weight, {"duration_seconds"}, label, ) @@ -361,14 +366,21 @@ def validate_session_exercise( label, known_exercise_ids, ): + is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item require_exact_keys( item, - SESSION_EXERCISE_KEYS, - SESSION_EXERCISE_KEYS + V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS, + (V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS) - {"sets", "continuous"}, 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( item["exercise_id"], f"{label}.exercise_id", @@ -492,6 +504,8 @@ def validate_session_exercise( ) 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( set_item, tracking_mode, @@ -553,6 +567,7 @@ def validate_sessions( ) seen_session_exercises = set() + seen_positions = set() for exercise_index, exercise in enumerate( exercises @@ -568,15 +583,20 @@ def validate_sessions( ) 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( - 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( - 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): @@ -647,9 +667,9 @@ def require_schema_v5(connection): "PRAGMA user_version;" ).fetchone()[0] - if version != 5: + if version not in (5, 6, 7): 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"] 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( """ INSERT INTO session_exercises( - session_row_id, + """ + columns + """session_row_id, exercise_row_id, recording_mode, data_fields, @@ -887,19 +918,13 @@ def import_set_session_exercise( target_sets, target_reps, target_duration_seconds, - target_weight_kg, - notes + target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - ?, ?, 'sets', ?, ?, 'none', 0, - NULL, NULL, NULL, NULL, NULL + """ + values + """?, ?, 'sets', ?, ?, 'none', 0, + NULL, NULL, NULL, NULL, NULL""" + equipment_values + """ ); """, - ( - session_row_id, - exercise_row, - item["data_fields"], - position, - ), + arguments, ) session_exercise_row_id = ( @@ -924,13 +949,13 @@ def import_set_session_exercise( reps, duration_seconds, weight_kg - ) VALUES(?, ?, ?, ?, NULL); + ) VALUES(?, ?, ?, ?, ?); """, ( session_exercise_row_id, set_index, reps, - duration, + duration, set_item.get("weight_kg"), ), ) @@ -941,10 +966,21 @@ def import_continuous_session_exercise( item, 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( """ INSERT INTO session_exercises( - session_row_id, + """ + columns + """session_row_id, exercise_row_id, recording_mode, data_fields, @@ -954,19 +990,13 @@ def import_continuous_session_exercise( target_sets, target_reps, target_duration_seconds, - target_weight_kg, - notes + target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - ?, ?, 'continuous', ?, ?, 'none', 0, - NULL, NULL, NULL, NULL, NULL + """ + values + """?, ?, 'continuous', ?, ?, 'none', 0, + NULL, NULL, NULL, NULL, NULL""" + equipment_values + """ ); """, - ( - session_row_id, - exercise_row, - item["data_fields"], - position, - ), + arguments, ) continuous = item["continuous"] @@ -1000,31 +1030,53 @@ def import_sessions( connection, session["session_id"], ): - report["sessions_skipped"] += 1 - continue + if payload["version"] == 1: + report["sessions_skipped"] += 1 + 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( + """ + INSERT INTO sessions( + session_id, + started_at, + ended_at, + session_type, + notes + ) VALUES(?, ?, NULL, ?, NULL); + """, + ( + session["session_id"], + session["started_at"], + session["session_type"], + ), + ) - cursor = connection.execute( - """ - INSERT INTO sessions( - session_id, - started_at, - ended_at, - session_type, - notes - ) VALUES(?, ?, NULL, ?, NULL); - """, - ( - session["session_id"], - session["started_at"], - session["session_type"], - ), - ) - - session_row_id = cursor.lastrowid + session_row_id = cursor.lastrowid for position, item in enumerate( session["exercises"] ): + position = item.get("position", position) mobile_id = item["exercise_id"] desktop_id = exercise_mapping.get( @@ -1188,6 +1240,7 @@ def run_import( "exercises_reconciled": 0, "exercises_skipped": 0, "sessions_imported": 0, + "sessions_reconciled": 0, "sessions_skipped": 0, "body_imported": 0, "body_skipped": 0, @@ -1255,6 +1308,7 @@ def print_report( "exercises_reconciled", "exercises_skipped", "sessions_imported", + "sessions_reconciled", "sessions_skipped", "body_imported", "body_skipped", diff --git a/tools/validate_json.py b/tools/validate_json.py index 3834655..f3e2b90 100755 --- a/tools/validate_json.py +++ b/tools/validate_json.py @@ -210,10 +210,7 @@ def validate_semantics(document: dict[str, Any]) -> None: validate_load_mode(workout, index) if "notes" in workout: - require_non_blank( - workout["notes"], - f"session.exercises[{index}].notes", - ) + require_non_blank(workout["notes"], f"session.exercises[{index}].notes") catalog_ids = set(catalog_by_id) if catalog_ids != workout_ids: @@ -224,11 +221,39 @@ def validate_semantics(document: dict[str, Any]) -> None: details.append(f"unreferenced catalog ids: {unreferenced}") if missing: details.append(f"missing catalog ids: {missing}") - raise TrainlogSemanticError( - "catalog/reference set mismatch: " + "; ".join(details) - ) + raise TrainlogSemanticError("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( validator: jsonschema.Draft202012Validator, document: Any, @@ -259,12 +284,17 @@ def validate_document( except (OSError, json.JSONDecodeError) as exc: return [str(exc)] - errors = structural_errors(validator, document) - if errors: - return errors + 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) + if errors: + return errors try: - validate_semantics(document) + if is_mobile_v2: + validate_mobile_export_v2(document) + else: + validate_semantics(document) except TrainlogSemanticError as exc: return [str(exc)] diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index e360f60..1d7eef0 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -11,7 +11,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 5 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 7 typedef struct TrainlogDatabase TrainlogDatabase; @@ -20,6 +20,22 @@ TrainlogStatus trainlog_database_open( 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); TrainlogStatus trainlog_database_schema_version( @@ -107,7 +123,10 @@ TrainlogStatus trainlog_database_list_weight_points( #define TRAINLOG_SET_SUMMARY_MAX 1024U 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 equipment_id[TRAINLOG_ID_MAX + 1U]; TrainlogTrackingMode tracking_mode; TrainlogRecordingMode recording_mode; TrainlogExerciseDataFields data_fields; @@ -231,7 +250,9 @@ TrainlogStatus trainlog_database_list_exercise_performance( /* TRAINLOG_SESSION_EDIT_API */ typedef struct TrainlogEditableExerciseRecord { + char entry_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]; TrainlogTrackingMode tracking_mode; TrainlogLoadMode load_mode; diff --git a/tui/include/trainlog/equipment_catalog.h b/tui/include/trainlog/equipment_catalog.h new file mode 100644 index 0000000..02e357b --- /dev/null +++ b/tui/include/trainlog/equipment_catalog.h @@ -0,0 +1,31 @@ +#ifndef TRAINLOG_EQUIPMENT_CATALOG_H +#define TRAINLOG_EQUIPMENT_CATALOG_H + +#include + +/* 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 diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h index e7663b5..6ead7c0 100644 --- a/tui/include/trainlog/model.h +++ b/tui/include/trainlog/model.h @@ -60,7 +60,11 @@ typedef struct TrainlogSetInput { } TrainlogSetInput; 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]; + /* Optional canonical ID from equipment-v1.json, never a SQLite row ID. */ + char equipment_id[TRAINLOG_ID_MAX + 1U]; TrainlogRecordingMode recording_mode; TrainlogExerciseDataFields data_fields; TrainlogLoadMode load_mode; diff --git a/tui/meson.build b/tui/meson.build index 40666f6..140613a 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -16,6 +16,13 @@ m_dep = cc.find_library('m', required: true) 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 = [ '-D_POSIX_C_SOURCE=200809L', '-Wconversion', @@ -37,6 +44,7 @@ trainlog_core_sources = files( 'src/measured_max.c', 'src/sync.c', ) +trainlog_core_sources += equipment_catalog_generated trainlog_core = static_library( 'trainlog_core', @@ -108,6 +116,14 @@ test( 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', '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', 'tools/sync_once.c', diff --git a/tui/src/database.c b/tui/src/database.c index b6f0546..e35003b 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -5,6 +5,8 @@ #include "trainlog/database.h" #include "trainlog/duration.h" +#include "trainlog/equipment_catalog.h" +#include "trainlog/id.h" #include #include @@ -17,7 +19,27 @@ struct TrainlogDatabase { 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;" "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 (" " 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" @@ -64,9 +87,9 @@ static const char *const SCHEMA_V5_SQL_A = " target_duration_seconds INTEGER" " CHECK (target_duration_seconds > 0)," " target_weight_kg REAL CHECK (target_weight_kg > 0.0)," + " equipment_id TEXT," " notes TEXT," " UNIQUE (session_row_id, position)," - " UNIQUE (session_row_id, exercise_row_id)," " CHECK (" " (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 (" " id INTEGER PRIMARY KEY," " 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;"; +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 = "BEGIN IMMEDIATE;" "ALTER TABLE sessions " @@ -489,7 +544,9 @@ static TrainlogStatus read_single_int_pragma( } static TrainlogStatus initialize_or_validate_schema( - TrainlogDatabase *database + TrainlogDatabase *database, + char *output_diagnostic, + size_t output_diagnostic_capacity ) { int version = 0; @@ -505,6 +562,13 @@ static TrainlogStatus initialize_or_validate_schema( status != TRAINLOG_STATUS_OK ) { + set_open_diagnostic( + output_diagnostic, + output_diagnostic_capacity, + "read schema version", + database->connection, + SQLITE_ERROR + ); return status; } @@ -512,6 +576,13 @@ static TrainlogStatus initialize_or_validate_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 TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; } @@ -527,7 +598,7 @@ static TrainlogStatus initialize_or_validate_schema( status = execute_sql( database, - SCHEMA_V5_SQL_A + SCHEMA_V7_SQL_A ); if ( @@ -537,7 +608,7 @@ static TrainlogStatus initialize_or_validate_schema( status = execute_sql( database, - SCHEMA_V5_SQL_B + SCHEMA_V7_SQL_B ); } } else { @@ -617,6 +688,10 @@ static TrainlogStatus initialize_or_validate_schema( } else if (version == 4) { status = TRAINLOG_STATUS_OK; + } else if (version == 5) { + status = TRAINLOG_STATUS_OK; + } else if (version == 6) { + status = TRAINLOG_STATUS_OK; } else { return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; @@ -643,12 +718,26 @@ static TrainlogStatus initialize_or_validate_schema( 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 ( status != 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( database->connection, "ROLLBACK;", @@ -665,19 +754,51 @@ TrainlogStatus trainlog_database_open( const char *path, 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; int rc; TrainlogStatus status; 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; } *output_database = NULL; + if (output_diagnostic != NULL && output_diagnostic_capacity > 0U) { + output_diagnostic[0] = '\0'; + } database = calloc(1U, sizeof(*database)); if (database == NULL) { + set_open_diagnostic( + output_diagnostic, + output_diagnostic_capacity, + "allocate database handle", + NULL, + SQLITE_NOMEM + ); return TRAINLOG_STATUS_SYSTEM_ERROR; } @@ -688,22 +809,47 @@ TrainlogStatus trainlog_database_open( NULL ); if (rc != SQLITE_OK) { + set_open_diagnostic( + output_diagnostic, + output_diagnostic_capacity, + "open database", + database->connection, + rc + ); trainlog_database_close(database); return TRAINLOG_STATUS_DATABASE_ERROR; } 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); return TRAINLOG_STATUS_DATABASE_ERROR; } status = execute_sql(database, "PRAGMA foreign_keys = ON;"); 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); return status; } - status = initialize_or_validate_schema(database); + status = initialize_or_validate_schema( + database, + output_diagnostic, + output_diagnostic_capacity + ); if (status != TRAINLOG_STATUS_OK) { trainlog_database_close(database); return status; @@ -1415,6 +1561,8 @@ static TrainlogStatus insert_session_exercise( sqlite3_int64 exercise_row_id; const char *load_mode; const char *recording_mode; + char generated_entry_id[TRAINLOG_GENERATED_ID_CAPACITY]; + const char *entry_id; int rc; TrainlogStatus status; @@ -1425,6 +1573,25 @@ static TrainlogStatus insert_session_exercise( 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_to_sql( input->load_mode @@ -1522,15 +1689,15 @@ static TrainlogStatus insert_session_exercise( rc = sqlite3_prepare_v2( database->connection, "INSERT INTO session_exercises(" - "session_row_id, exercise_row_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, notes" + "target_weight_kg, notes, equipment_id" ") VALUES(" - "?1, ?2, ?3, ?4, ?5, ?6, " - "?7, ?8, ?9, ?10, ?11, ?12" + "?1, ?2, ?3, ?4, ?5, ?6, ?7, " + "?8, ?9, ?10, ?11, ?12, ?13, ?14" ");", -1, &statement, @@ -1541,16 +1708,20 @@ static TrainlogStatus insert_session_exercise( return TRAINLOG_STATUS_DATABASE_ERROR; } + rc = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT); + + if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 1, + 2, session_row_id ); + } if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 2, + 3, exercise_row_id ); } @@ -1558,7 +1729,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_text( statement, - 3, + 4, recording_mode, -1, SQLITE_STATIC @@ -1568,7 +1739,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 4, + 5, (sqlite3_int64)input->data_fields ); } @@ -1576,7 +1747,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_int64( statement, - 5, + 6, (sqlite3_int64)position ); } @@ -1584,7 +1755,7 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_text( statement, - 6, + 7, load_mode, -1, SQLITE_STATIC @@ -1594,30 +1765,17 @@ static TrainlogStatus insert_session_exercise( if (rc == SQLITE_OK) { rc = sqlite3_bind_int( statement, - 7, + 8, input->rest_seconds ); } if (rc == SQLITE_OK) { 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( statement, 9, - input->target_reps + input->target_sets ) : sqlite3_bind_null( statement, @@ -1626,11 +1784,11 @@ static TrainlogStatus insert_session_exercise( } if (rc == SQLITE_OK) { - rc = input->target_duration_seconds > 0 + rc = input->target_reps > 0 ? sqlite3_bind_int( statement, 10, - input->target_duration_seconds + input->target_reps ) : sqlite3_bind_null( statement, @@ -1639,11 +1797,11 @@ static TrainlogStatus insert_session_exercise( } if (rc == SQLITE_OK) { - rc = input->target_has_weight - ? sqlite3_bind_double( + rc = input->target_duration_seconds > 0 + ? sqlite3_bind_int( statement, 11, - input->target_weight_kg + input->target_duration_seconds ) : sqlite3_bind_null( 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) { rc = input->notes != NULL && input->notes[0] != '\0' ? sqlite3_bind_text( statement, - 12, + 13, input->notes, -1, SQLITE_TRANSIENT ) : sqlite3_bind_null( 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) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; @@ -2276,6 +2454,7 @@ TrainlogStatus trainlog_database_insert_body_observation( SQLITE_TRANSIENT ); } + if (rc == SQLITE_OK) { rc = observation->session_id != NULL && observation->session_id[0] != '\0' @@ -2672,6 +2851,8 @@ TrainlogStatus trainlog_database_get_session_details( "COALESCE(se.target_duration_seconds, 0), " "se.target_weight_kg, " "ca.duration_seconds, ca.speed_kmh, ca.distance_km, " + "se.equipment_id, " + "se.entry_id, " "se.id " "FROM session_exercises AS se " "JOIN sessions AS s " @@ -2842,7 +3023,10 @@ TrainlogStatus trainlog_database_get_session_details( sqlite3_column_text(exercises, 4); 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; @@ -2865,6 +3049,17 @@ TrainlogStatus trainlog_database_get_session_details( 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( detail->name, sizeof(detail->name), @@ -3676,7 +3871,8 @@ TrainlogStatus trainlog_database_load_session_editable( "COALESCE(se.target_reps, 0), " "COALESCE(se.target_duration_seconds, 0), " "se.target_weight_kg, " - "COALESCE(se.notes, '') " + "COALESCE(se.notes, ''), " + "COALESCE(se.equipment_id, ''), se.entry_id " "FROM session_exercises AS se " "JOIN sessions AS s " "ON s.id = se.session_row_id " @@ -3909,6 +4105,23 @@ TrainlogStatus trainlog_database_load_session_editable( 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( record->exercise_id, sizeof(record->exercise_id), diff --git a/tui/src/main.c b/tui/src/main.c index 4379173..8f55121 100644 --- a/tui/src/main.c +++ b/tui/src/main.c @@ -83,6 +83,7 @@ static int build_database_path(char *output, size_t output_size) int main(void) { char database_path[PATH_MAX]; + char database_diagnostic[256]; TrainlogDatabase *database = NULL; TrainlogStatus status; int result; @@ -92,12 +93,21 @@ int main(void) 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) { (void)fprintf( stderr, - "trainlog: unable to open database (%d)\n", - (int)status + "trainlog: unable to open database '%s' (status %d): %s\n", + database_path, + (int)status, + database_diagnostic[0] != '\0' + ? database_diagnostic + : "no SQLite diagnostic available" ); return 1; } diff --git a/tui/src/sync.c b/tui/src/sync.c index 9ad774c..07665d4 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -28,11 +28,20 @@ #define SYNC_REQUEST_TEXT_MAX 4095U 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"; +static const char *const PC_MOBILE_EXPORT_NAME = + "trainlog-pc-mobile-export-v2.json"; + static const char *const PC_CATALOG_NAME = "trainlog-pc-catalog-v1.json"; +static const char *const EQUIPMENT_ASSOCIATIONS_NAME = + "trainlog-equipment-associations-v2.json"; + static const char *const SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"; @@ -40,11 +49,17 @@ static const char *const SYNC_RECEIPT_NAME = "trainlog-sync-receipt-v1.json"; 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 = "/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 = "/tmp/trainlog-sync-request-v1.json"; @@ -57,6 +72,9 @@ static const char *const MOBILE_IMPORT_RESULT = static const char *const PC_CATALOG_RESULT = "/tmp/trainlog-pc-catalog-result.txt"; +static const char *const EQUIPMENT_ASSOCIATIONS_RESULT = + "/tmp/trainlog-equipment-associations-result.txt"; + typedef struct SyncSilence { int saved_stdout; int saved_stderr; @@ -947,6 +965,7 @@ static bool sync_read_text( static TrainlogStatus sync_run_python_tool( const char *tool_name, const char *argument, + const char *database_path, const char *result_path, char *output, size_t output_size @@ -1028,13 +1047,28 @@ static TrainlogStatus sync_run_python_tool( result_fd ); - execlp( - "python3", - "python3", - tool, - argument, - (char *)NULL - ); + 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( + "python3", + "python3", + tool, + argument, + (char *)NULL + ); + } _exit(127); } @@ -2192,6 +2226,10 @@ TrainlogStatus trainlog_sync_run( SYNC_REQUEST_TEXT_MAX + 1U ]; + char database_path[ + PATH_MAX + 1U + ]; + uint32_t folder_id = 0U; uint64_t ignored_size = 0U; int lock_fd = -1; @@ -2400,6 +2438,16 @@ TrainlogStatus trainlog_sync_run( 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 = sync_receive_named( &device, @@ -2413,6 +2461,12 @@ TrainlogStatus trainlog_sync_run( status != 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( output->error, sizeof(output->error), @@ -2428,6 +2482,7 @@ TrainlogStatus trainlog_sync_run( sync_run_python_tool( "import_mobile_export.py", MOBILE_EXPORT_LOCAL, + database_path, MOBILE_IMPORT_RESULT, tool_output, sizeof(tool_output) @@ -2470,10 +2525,43 @@ TrainlogStatus trainlog_sync_run( 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 = sync_run_python_tool( "export_pc_catalog.py", PC_CATALOG_LOCAL, + database_path, PC_CATALOG_RESULT, tool_output, sizeof(tool_output) @@ -2517,6 +2605,21 @@ TrainlogStatus trainlog_sync_run( "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 = sync_publish_named( &device, @@ -2540,6 +2643,28 @@ TrainlogStatus trainlog_sync_run( 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; final_status = TRAINLOG_STATUS_OK; diff --git a/tui/src/tui.c b/tui/src/tui.c index 5289203..560fa9c 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -7172,89 +7172,9 @@ static void screen_session_detail( static void history_ascii_header(void) { - static const char *const logo[] = { - "TTTTT RRRR AAA IIIII N N L OOO GGG ", - " T R R A A I NN N L O O G ", - " 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 - ) - ); + /* CONTRACT: history shares the current page shell; the old local logo + * occupied six unrelated rows and made this screen an exception. */ + section_ascii_header("Historique"); } static void history_scrollbar( diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index f7e9c9e..c143730 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -49,6 +49,27 @@ static bool test_database_open_and_schema(void) 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) { char first[TRAINLOG_GENERATED_ID_CAPACITY]; @@ -262,6 +283,7 @@ int main(void) { static const struct TestCase tests[] = { {"database_open_and_schema", test_database_open_and_schema}, + {"database_open_diagnostic", test_database_open_diagnostic}, {"generated_ids", test_generated_ids}, {"session_insert", test_session_insert}, {"body_weight_history", test_body_weight_history}, diff --git a/tui/tests/test_equipment_catalog.c b/tui/tests/test_equipment_catalog.c new file mode 100644 index 0000000..cb72318 --- /dev/null +++ b/tui/tests/test_equipment_catalog.c @@ -0,0 +1,24 @@ +#include +#include + +#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; +} diff --git a/tui/tests/test_schema_v5_migration.c b/tui/tests/test_schema_v5_migration.c index 5ed5032..a0444e8 100644 --- a/tui/tests/test_schema_v5_migration.c +++ b/tui/tests/test_schema_v5_migration.c @@ -1,6 +1,6 @@ /** * @file test_schema_v5_migration.c - * @brief Direct v4 -> v5 migration regression test. + * @brief Direct v4 -> v7 migration regression test. */ #include @@ -27,7 +27,7 @@ } \ } while (0) -static bool test_v4_to_v5_preserves_session(void) +static bool test_v4_to_v7_preserves_session(void) { char path[] = "/tmp/trainlog-schema-v4-v5-XXXXXX"; @@ -188,7 +188,7 @@ static bool test_v4_to_v5_preserves_session(void) ); CHECK( - version == 5 + version == 7 ); CHECK( @@ -228,7 +228,7 @@ static bool test_v4_to_v5_preserves_session(void) int main(void) { CHECK( - test_v4_to_v5_preserves_session() + test_v4_to_v7_preserves_session() ); (void)printf( diff --git a/tui/tests/test_session_detail.c b/tui/tests/test_session_detail.c index 444f578..da35e48 100644 --- a/tui/tests/test_session_detail.c +++ b/tui/tests/test_session_detail.c @@ -74,6 +74,8 @@ static bool test_session_details(void) "%s", "ex_detail" ); + (void)snprintf(exercise.equipment_id, sizeof(exercise.equipment_id), + "%s", "leg_press"); exercise.load_mode = TRAINLOG_LOAD_EXTERNAL; @@ -144,6 +146,7 @@ static bool test_session_details(void) CHECK(details[0].target_sets == 3); CHECK(details[0].target_reps == 5); CHECK(details[0].rest_seconds == 60); + CHECK(strcmp(details[0].equipment_id, "leg_press") == 0); CHECK(details[0].actual_set_count == 3U); CHECK( strcmp(