fix(tui): use shared header on history screen
This commit is contained in:
parent
c9c4d3b9ce
commit
7093dd5606
41 changed files with 2689 additions and 321 deletions
29
CHANGELOG.md
29
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
|
||||
|
|
|
|||
34
README.md
34
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_<uuid-v4>` 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
|
||||
enum class EquipmentLoadSemantics {
|
||||
EXTERNAL,
|
||||
ASSISTANCE,
|
||||
BODYWEIGHT,
|
||||
CARDIO,
|
||||
}
|
||||
|
||||
data class EquipmentCatalogEntry(
|
||||
val equipmentId: String,
|
||||
val labelName: String,
|
||||
val displayName: String,
|
||||
val aliases: List<String>,
|
||||
val type: String,
|
||||
val loadSemantics: EquipmentLoadSemantics,
|
||||
)
|
||||
|
||||
data class ExerciseEquipmentCatalogRelation(
|
||||
val exerciseId: String,
|
||||
val equipmentId: String,
|
||||
val loadSemantics: EquipmentLoadSemantics,
|
||||
)
|
||||
|
||||
/**
|
||||
* CONTRACT: `catalog/equipment-v1.json` is shared repository data. This
|
||||
* reader deliberately has no fallback list: a missing or malformed manifest
|
||||
* is an explicit deployment error, never a silently divergent Android list.
|
||||
*/
|
||||
object EquipmentCatalog {
|
||||
private const val ASSET_NAME = "equipment-v1.json"
|
||||
|
||||
fun load(context: Context): List<EquipmentCatalogEntry> {
|
||||
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
|
||||
JSONObject(it.readText())
|
||||
}
|
||||
check(root.getString("format") == "trainlog-equipment-catalog")
|
||||
check(root.getInt("version") == 1)
|
||||
val seen = mutableSetOf<String>()
|
||||
return buildList {
|
||||
val entries = root.getJSONArray("equipment")
|
||||
for (index in 0 until entries.length()) {
|
||||
val item = entries.getJSONObject(index)
|
||||
val id = item.getString("id").trim()
|
||||
check(id.isNotEmpty()) { "equipment_id vide" }
|
||||
check(seen.add(id)) { "equipment_id dupliqué: $id" }
|
||||
val aliases = item.getJSONArray("aliases")
|
||||
val parsedAliases = List(aliases.length()) { aliases.getString(it).trim() }
|
||||
check(parsedAliases.all { it.isNotEmpty() }) { "alias vide pour $id" }
|
||||
val labelName = item.getString("label_name").trim()
|
||||
val displayName = item.getString("display_name").trim()
|
||||
check(displayName.isNotEmpty()) { "display_name vide pour $id" }
|
||||
val type = item.getString("type").trim()
|
||||
check(type.isNotEmpty()) { "type vide pour $id" }
|
||||
add(
|
||||
EquipmentCatalogEntry(
|
||||
equipmentId = id,
|
||||
labelName = labelName,
|
||||
displayName = displayName,
|
||||
aliases = parsedAliases,
|
||||
type = type,
|
||||
loadSemantics = EquipmentLoadSemantics.valueOf(
|
||||
item.getString("load_semantics").uppercase(Locale.ROOT),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exerciseEquipmentRelations(
|
||||
context: Context,
|
||||
): List<ExerciseEquipmentCatalogRelation> {
|
||||
val root = context.assets.open(ASSET_NAME).bufferedReader().use {
|
||||
JSONObject(it.readText())
|
||||
}
|
||||
val knownEquipment = load(context).map { it.equipmentId }.toSet()
|
||||
val relations = root.getJSONArray("exercise_equipment")
|
||||
return List(relations.length()) { index ->
|
||||
val item = relations.getJSONObject(index)
|
||||
val equipmentId = item.getString("equipment_id")
|
||||
check(equipmentId in knownEquipment)
|
||||
ExerciseEquipmentCatalogRelation(
|
||||
exerciseId = item.getString("exercise_id"),
|
||||
equipmentId = equipmentId,
|
||||
loadSemantics = EquipmentLoadSemantics.valueOf(
|
||||
item.getString("load_semantics").uppercase(Locale.ROOT),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun search(
|
||||
entries: List<EquipmentCatalogEntry>,
|
||||
query: String,
|
||||
): List<EquipmentCatalogEntry> {
|
||||
val needle = normalize(query)
|
||||
if (needle.isEmpty()) return entries
|
||||
return entries.filter { entry ->
|
||||
sequenceOf(entry.displayName, entry.labelName)
|
||||
.plus(entry.aliases.asSequence())
|
||||
.any { normalize(it).contains(needle) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalize(value: String): String =
|
||||
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||
.replace("\\p{M}+".toRegex(), "")
|
||||
.lowercase(Locale.ROOT)
|
||||
.trim()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<EquipmentCatalogEntry> {
|
||||
val output = mutableListOf<EquipmentCatalogEntry>()
|
||||
database.readableDatabase.query(
|
||||
"equipment",
|
||||
arrayOf("equipment_id", "label_name", "display_name", "equipment_type", "load_semantics"),
|
||||
null, null, null, null, "display_name COLLATE NOCASE, equipment_id",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getString(0)
|
||||
val aliases = mutableListOf<String>()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT alias FROM equipment_aliases ea JOIN equipment e ON e.id = ea.equipment_row_id WHERE e.equipment_id = ? ORDER BY alias COLLATE NOCASE;",
|
||||
arrayOf(id),
|
||||
).use { aliasCursor -> while (aliasCursor.moveToNext()) aliases += aliasCursor.getString(0) }
|
||||
output += EquipmentCatalogEntry(
|
||||
equipmentId = id,
|
||||
labelName = cursor.getString(1),
|
||||
displayName = cursor.getString(2),
|
||||
aliases = aliases,
|
||||
type = cursor.getString(3),
|
||||
loadSemantics = EquipmentLoadSemantics.valueOf(cursor.getString(4).uppercase(Locale.ROOT)),
|
||||
)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
|
||||
EquipmentCatalog.search(listEquipment(), query)
|
||||
|
||||
/** Custom equipment is local user data, not an edit to the bundled manifest. */
|
||||
fun createCustomEquipment(name: String): CreateEquipmentResult {
|
||||
val displayName = name.trim().replace(Regex("\\s+"), " ")
|
||||
if (displayName.isBlank() || displayName.length > 120) return CreateEquipmentResult.Invalid
|
||||
return try {
|
||||
val db = database.writableDatabase
|
||||
val duplicate = db.rawQuery(
|
||||
"SELECT 1 FROM equipment WHERE lower(display_name) = lower(?) LIMIT 1;", arrayOf(displayName),
|
||||
).use { it.moveToFirst() }
|
||||
if (duplicate) return CreateEquipmentResult.Conflict
|
||||
val entry = EquipmentCatalogEntry(
|
||||
equipmentId = "eq_" + UUID.randomUUID(), labelName = "", displayName = displayName,
|
||||
aliases = emptyList(), type = "custom_machine", loadSemantics = EquipmentLoadSemantics.EXTERNAL,
|
||||
)
|
||||
val values = ContentValues().apply {
|
||||
put("equipment_id", entry.equipmentId); put("label_name", entry.labelName)
|
||||
put("display_name", entry.displayName); put("equipment_type", entry.type)
|
||||
put("load_semantics", "external")
|
||||
}
|
||||
db.insertOrThrow("equipment", null, values)
|
||||
CreateEquipmentResult.Created(entry)
|
||||
} catch (error: SQLiteConstraintException) { CreateEquipmentResult.Conflict
|
||||
} catch (error: Exception) { CreateEquipmentResult.DatabaseError(error.message ?: "erreur SQLite") }
|
||||
}
|
||||
|
||||
fun listExercises(): List<ExerciseProfile> {
|
||||
val output =
|
||||
mutableListOf<ExerciseProfile>()
|
||||
|
|
@ -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<String>()
|
||||
for (index in 0 until entries.length()) {
|
||||
val entry = entries.getJSONObject(index)
|
||||
val entryId = entry.optString("entry_id")
|
||||
val exerciseId = entry.optString("exercise_id")
|
||||
val position = entry.optInt("position", -1)
|
||||
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
|
||||
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
|
||||
}
|
||||
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
|
||||
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||
val recording = entry.optString("recording_mode")
|
||||
val tracking = entry.optString("tracking_mode")
|
||||
if (recording !in setOf("sets", "continuous") || tracking !in setOf("reps", "duration")) {
|
||||
return MobileSessionImportResult.Invalid("Profil V2 invalide.")
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId)
|
||||
put("position", position); put("recording_mode", recording); put("tracking_mode", tracking)
|
||||
put("data_fields", entry.optInt("data_fields", 0)); put("equipment_row_id", lookupEquipmentRowIdOrNull(db, entry.optString("equipment_id").ifBlank { null }))
|
||||
}
|
||||
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
||||
if (recording == "continuous") {
|
||||
val c = entry.optJSONObject("continuous") ?: return MobileSessionImportResult.Invalid("Activité continue manquante.")
|
||||
db.insertOrThrow("continuous_activity", null, ContentValues().apply {
|
||||
put("session_exercise_row_id", occurrence); put("duration_seconds", c.optInt("duration_seconds", 0))
|
||||
if (c.has("speed_kmh")) put("speed_kmh", c.getDouble("speed_kmh")); if (c.has("distance_km")) put("distance_km", c.getDouble("distance_km"))
|
||||
})
|
||||
} else {
|
||||
val sets = entry.optJSONArray("sets") ?: return MobileSessionImportResult.Invalid("Séries manquantes.")
|
||||
for (setIndex in 0 until sets.length()) {
|
||||
val set = sets.getJSONObject(setIndex)
|
||||
db.insertOrThrow("performed_sets", null, ContentValues().apply {
|
||||
put("session_exercise_row_id", occurrence); put("position", setIndex)
|
||||
if (tracking == "reps") put("reps", set.optInt("reps", -1)) else put("duration_seconds", set.optInt("duration_seconds", 0))
|
||||
if (set.has("weight_kg")) put("weight_kg", set.getDouble("weight_kg"))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
MobileSessionImportResult.Applied(sessions.length())
|
||||
} catch (_: Exception) { MobileSessionImportResult.DatabaseError
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: this companion artifact is deliberately outside frozen mobile
|
||||
* export v1. `(session_id, exercise_id)` is stable and unambiguous under
|
||||
* the one-entry-per-exercise-per-session model. `cleared` is intentional
|
||||
* state, unlike an absent v1 artifact which conveys no equipment signal.
|
||||
*/
|
||||
fun buildEquipmentAssociationsJson(): String {
|
||||
val root = JSONObject()
|
||||
.put("format", "trainlog-equipment-associations")
|
||||
.put("version", 2)
|
||||
.put("generated_at", OffsetDateTime.now().toString())
|
||||
val associations = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT s.session_id, se.entry_id, e.exercise_id, eq.equipment_id FROM session_exercises se " +
|
||||
"JOIN sessions s ON s.id = se.session_row_id " +
|
||||
"JOIN exercises e ON e.id = se.exercise_row_id " +
|
||||
"LEFT JOIN equipment eq ON eq.id = se.equipment_row_id " +
|
||||
"ORDER BY s.started_at ASC, s.id ASC, se.position ASC;",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val item = JSONObject()
|
||||
.put("session_id", cursor.getString(0))
|
||||
.put("entry_id", cursor.getString(1))
|
||||
.put("exercise_id", cursor.getString(2))
|
||||
if (cursor.isNull(3)) item.put("state", "cleared") else {
|
||||
item.put("state", "set")
|
||||
item.put("equipment_id", cursor.getString(3))
|
||||
}
|
||||
associations.put(item)
|
||||
}
|
||||
}
|
||||
return root.put("associations", associations).toString()
|
||||
}
|
||||
|
||||
fun applyPcEquipmentAssociationsJson(json: String): EquipmentAssociationImportResult {
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return EquipmentAssociationImportResult.Invalid("Extension équipement JSON invalide.")
|
||||
}
|
||||
val version = root.optInt("version", -1)
|
||||
if (root.optString("format") != "trainlog-equipment-associations" || version !in setOf(1, 2)) {
|
||||
return EquipmentAssociationImportResult.Invalid("Extension équipement non supportée.")
|
||||
}
|
||||
val items = root.optJSONArray("associations")
|
||||
?: return EquipmentAssociationImportResult.Invalid("Associations équipement manquantes.")
|
||||
val known = listEquipment().map { it.equipmentId }.toSet()
|
||||
val db = database.writableDatabase
|
||||
var updated = 0
|
||||
db.beginTransaction()
|
||||
try {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
val sessionId = item.optString("session_id")
|
||||
val exerciseId = item.optString("exercise_id")
|
||||
val entryId = if (version == 2) item.optString("entry_id") else null
|
||||
val state = item.optString("state")
|
||||
if (sessionId.isBlank() || exerciseId.isBlank() || state !in setOf("set", "cleared") || (version == 2 && entryId.isNullOrBlank())) {
|
||||
return EquipmentAssociationImportResult.Invalid("Association équipement invalide.")
|
||||
}
|
||||
val equipmentId = if (state == "set") item.optString("equipment_id") else null
|
||||
if (state == "set" && (equipmentId.isNullOrBlank() || equipmentId !in known)) {
|
||||
return EquipmentAssociationImportResult.Invalid("Équipement inconnu : $equipmentId")
|
||||
}
|
||||
val row = db.rawQuery(
|
||||
if (version == 2) {
|
||||
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=? AND se.entry_id=?;"
|
||||
} else {
|
||||
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND e.exercise_id=?;"
|
||||
},
|
||||
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId),
|
||||
).use { cursor ->
|
||||
val first = if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||
if (version == 1 && first != null && cursor.moveToNext()) null else first
|
||||
}
|
||||
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
|
||||
val values = ContentValues()
|
||||
if (equipmentId == null) values.putNull("equipment_row_id") else {
|
||||
val equipmentRowId = lookupEquipmentRowIdOrNull(db, equipmentId)!!
|
||||
values.put("equipment_row_id", equipmentRowId)
|
||||
}
|
||||
updated += db.update("session_exercises", values, "id = ?", arrayOf(row.toString()))
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
return EquipmentAssociationImportResult.Applied(updated)
|
||||
} catch (_: Exception) {
|
||||
return EquipmentAssociationImportResult.DatabaseError
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
fun saveBodyObservation(
|
||||
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<Any>(
|
||||
rowId,
|
||||
alias,
|
||||
equipmentAliasNormalize(alias),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
EquipmentCatalog.exerciseEquipmentRelations(appContext).forEach { relation ->
|
||||
db.rawQuery(
|
||||
"SELECT id FROM equipment WHERE equipment_id = ?;",
|
||||
arrayOf(relation.equipmentId),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) {
|
||||
"Seeded equipment missing: ${relation.equipmentId}"
|
||||
}
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO catalog_exercise_equipment(" +
|
||||
"exercise_id, equipment_row_id, load_semantics) VALUES(?, ?, ?);",
|
||||
arrayOf<Any>(
|
||||
relation.exerciseId,
|
||||
cursor.getLong(0),
|
||||
relation.loadSemantics.name.lowercase(Locale.ROOT),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SessionSetDraft> = 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,
|
||||
|
|
|
|||
|
|
@ -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<String?>(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)}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Double?>? {
|
||||
if (text.trim().isEmpty()) return List(count) { null }
|
||||
val values = text.split(';').map { token ->
|
||||
token.trim().replace(',', '.').toDoubleOrNull()
|
||||
}
|
||||
if (values.any { it == null || !it.isFinite() || it < 0.0 }) return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val parsed = values as List<Double>
|
||||
return when {
|
||||
parsed.size == 1 -> List(count) { parsed.single() }
|
||||
parsed.size == count -> parsed
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun draftSummary(
|
||||
draft: SessionExerciseDraft,
|
||||
): String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class EquipmentCatalogTest {
|
||||
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
|
||||
@Test
|
||||
fun canonicalManifestHasStableCompleteSearchableEntries() {
|
||||
val entries = EquipmentCatalog.load(context)
|
||||
assertEquals(38, entries.size)
|
||||
assertEquals(entries.size, entries.map { it.equipmentId }.toSet().size)
|
||||
assertTrue(entries.all { it.equipmentId.isNotBlank() && it.displayName.isNotBlank() })
|
||||
assertTrue(entries.all { entry -> entry.aliases.all { it.isNotBlank() } })
|
||||
assertTrue(entries.all { it.type.isNotBlank() })
|
||||
assertTrue(EquipmentCatalog.search(entries, "LEG PRESS").any { it.equipmentId == "leg_press" })
|
||||
assertTrue(EquipmentCatalog.search(entries, "Presse à pectoraux").any { it.equipmentId == "vertical_chest_press" })
|
||||
assertTrue(EquipmentCatalog.search(entries, "presse à cuisses").any { it.equipmentId == "leg_press" })
|
||||
assertTrue(EquipmentCatalog.search(entries, "ischio").map { it.equipmentId }.containsAll(listOf("seated_leg_curl", "prone_leg_curl")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistedMachineIsOnePhysicalEquipmentWithAssistanceSemantics() {
|
||||
val entries = EquipmentCatalog.load(context)
|
||||
assertEquals(EquipmentLoadSemantics.ASSISTANCE, entries.single { it.equipmentId == "assisted_dip_chin_machine" }.loadSemantics)
|
||||
val relations = EquipmentCatalog.exerciseEquipmentRelations(context)
|
||||
assertEquals(setOf("assisted_chin", "assisted_dip"), relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.map { it.exerciseId }.toSet())
|
||||
assertTrue(relations.filter { it.equipmentId == "assisted_dip_chin_machine" }.all { it.loadSemantics == EquipmentLoadSemantics.ASSISTANCE })
|
||||
}
|
||||
}
|
||||
|
|
@ -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;")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
catalog/equipment-v1.json
Normal file
48
catalog/equipment-v1.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"format": "trainlog-equipment-catalog",
|
||||
"version": 1,
|
||||
"equipment": [
|
||||
{"id":"diverging_lat_pulldown","label_name":"DIVERGING LAT PULLDOWN","display_name":"Diverging Lat Pulldown — Tirage vertical divergent","aliases":["tirage vertical divergent","lat pulldown divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"lat_pull","label_name":"LAT PULL","display_name":"Lat Pull — Tirage vertical","aliases":["tirage vertical","lat pull","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"lat_pulldown","label_name":"LAT PULLDOWN","display_name":"Lat Pulldown — Tirage vertical","aliases":["tirage vertical","tirage poitrine","lat pulldown"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"seated_row","label_name":"SEATED ROW","display_name":"Seated Row — Tirage horizontal assis","aliases":["rowing assis","tirage horizontal","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"diverging_seated_row","label_name":"DIVERGING SEATED ROW","display_name":"Diverging Seated Row — Tirage horizontal divergent","aliases":["rowing divergent","tirage horizontal divergent","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"low_row","label_name":"LOW ROW","display_name":"Low Row — Tirage horizontal bas","aliases":["rowing bas","tirage bas","dos"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"vertical_chest_press","label_name":"VERTICAL CHEST PRESS","display_name":"Vertical Chest Press — Presse à pectoraux","aliases":["chest press","presse poitrine","développé machine","pectoraux"],"type":"machine","load_semantics":"external"},
|
||||
{"id":"converging_shoulder_press","label_name":"CONVERGING SHOULDER PRESS","display_name":"Converging Shoulder Press — Presse épaules convergente","aliases":["shoulder press","développé épaules","presse épaules","épaules"],"type":"machine","load_semantics":"external"},
|
||||
{"id":"rear_delt_pec_fly","label_name":"REAR DELT / PEC FLY","display_name":"Rear Delt / Pec Fly — Oiseaux / Pec Fly","aliases":["pec fly","butterfly","pec deck","rear delt","reverse fly","oiseaux","deltoïdes postérieurs","pectoraux"],"type":"selectorized_combo_machine","load_semantics":"external"},
|
||||
{"id":"leg_press","label_name":"LEG PRESS","display_name":"Leg Press — Presse à cuisses","aliases":["presse à cuisses","presse jambes","jambes","leg press"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"plate_loaded_leg_press","label_name":"","display_name":"Leg Press à disques — Presse à cuisses","aliases":["presse à disques","plate loaded leg press","leg press"],"type":"plate_loaded_machine","load_semantics":"external"},
|
||||
{"id":"perfect_squat","label_name":"PERFECT SQUAT","display_name":"Perfect Squat — Squat guidé","aliases":["squat machine","squat guidé","jambes","perfect squat"],"type":"plate_loaded_machine","load_semantics":"external"},
|
||||
{"id":"leg_extension","label_name":"LEG EXTENSION","display_name":"Leg Extension — Extension des jambes","aliases":["extension quadriceps","quadriceps","leg extension"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"seated_leg_curl","label_name":"SEATED LEG CURL","display_name":"Seated Leg Curl — Leg curl assis","aliases":["leg curl assis","ischio","ischio-jambiers","flexion jambes"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"prone_leg_curl","label_name":"PRONE LEG CURL","display_name":"Prone Leg Curl — Leg curl allongé","aliases":["leg curl couché","leg curl allongé","ischio","ischio-jambiers"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"hip_abduction","label_name":"HIP ABDUCTION","display_name":"Hip Abduction — Abducteurs","aliases":["abducteurs","ouverture jambes","fessiers","hip abduction"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"hip_adduction","label_name":"HIP ADDUCTION","display_name":"Hip Adduction — Adducteurs","aliases":["adducteurs","fermeture jambes","hip adduction"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"abdominal","label_name":"ABDOMINAL","display_name":"Abdominal — Crunch machine","aliases":["abdos","crunch","abdominal machine"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"back_extension","label_name":"BACK EXTENSION","display_name":"Back Extension — Extension lombaires","aliases":["lombaires","extension dos","bas du dos","back extension"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"rotary_torso","label_name":"ROTARY TORSO","display_name":"Rotary Torso — Rotation du buste","aliases":["obliques","rotation tronc","rotation buste","rotary torso"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"abdominal_bench","label_name":"","display_name":"Banc à abdominaux incliné","aliases":["banc abdos","crunch bench","sit-up bench"],"type":"bench","load_semantics":"bodyweight"},
|
||||
{"id":"arm_curl","label_name":"ARM CURL","display_name":"Arm Curl — Curl biceps","aliases":["biceps","curl","curl machine"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"seated_dip","label_name":"SEATED DIP","display_name":"Seated Dip — Dips assis / Triceps","aliases":["triceps","dips assis","seated dip"],"type":"selectorized_machine","load_semantics":"external"},
|
||||
{"id":"assisted_dip_chin_machine","label_name":"","display_name":"Machine Chin / Dip Assist","aliases":["chin assist","dip assist","traction assistée","dips assistés"],"type":"assisted_bodyweight_machine","load_semantics":"assistance"},
|
||||
{"id":"functional_trainer","label_name":"","display_name":"Poulie réglable / Functional Trainer","aliases":["poulie","câble","cable machine","crossover","functional trainer"],"type":"cable_machine","load_semantics":"external"},
|
||||
{"id":"dual_adjustable_pulley","label_name":"","display_name":"Double poulie réglable","aliases":["double poulie","crossover","cable crossover"],"type":"cable_machine","load_semantics":"external"},
|
||||
{"id":"dumbbells","label_name":"","display_name":"Haltères","aliases":[],"type":"free_weight","load_semantics":"external"},
|
||||
{"id":"kettlebells","label_name":"","display_name":"Kettlebells","aliases":[],"type":"free_weight","load_semantics":"external"},
|
||||
{"id":"weighted_bag","label_name":"","display_name":"Sac lesté","aliases":["sandbag","power bag"],"type":"free_weight","load_semantics":"external"},
|
||||
{"id":"medicine_ball","label_name":"","display_name":"Medicine Ball","aliases":["med ball","ballon lesté"],"type":"functional_equipment","load_semantics":"external"},
|
||||
{"id":"suspension_trainer","label_name":"","display_name":"Sangles de suspension","aliases":["TRX","suspension trainer"],"type":"functional_equipment","load_semantics":"bodyweight"},
|
||||
{"id":"battle_ropes","label_name":"","display_name":"Battle Ropes — Cordes ondulatoires","aliases":["corde","battle rope","cordes"],"type":"functional_equipment","load_semantics":"bodyweight"},
|
||||
{"id":"treadmill","label_name":"","display_name":"Tapis de course","aliases":["treadmill","tapis"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||
{"id":"upright_bike","label_name":"","display_name":"Vélo droit","aliases":["exercise bike","vélo cardio"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||
{"id":"recumbent_bike","label_name":"","display_name":"Vélo semi-allongé","aliases":["vélo couché","recumbent bike"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||
{"id":"indoor_cycle","label_name":"","display_name":"Vélo indoor / Spinning","aliases":["spinning","body bike","indoor bike"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||
{"id":"rowing_machine","label_name":"","display_name":"Rameur","aliases":["rowing ergometer","rower"],"type":"cardio_machine","load_semantics":"cardio"},
|
||||
{"id":"stair_climber","label_name":"","display_name":"Escalier / Stair Climber","aliases":["stairmaster","climber","escalier","stepmill"],"type":"cardio_machine","load_semantics":"cardio"}
|
||||
],
|
||||
"exercise_equipment": [
|
||||
{"exercise_id":"assisted_chin","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"},
|
||||
{"exercise_id":"assisted_dip","equipment_id":"assisted_dip_chin_machine","load_semantics":"assistance"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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_<uuid-v4>
|
|||
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 <exercice>`; saving replaces that
|
||||
entry in place, while cancelling only discards the form and preserves it.
|
||||
|
||||
Android session entry exposes:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
tests/test_equipment_associations_exchange.py
Normal file
41
tests/test_equipment_associations_exchange.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Exercise the real desktop companion exporter/importer on temporary DBs."""
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def make_db(path, equipment):
|
||||
db = sqlite3.connect(path)
|
||||
db.executescript("""
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE, session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
|
||||
""")
|
||||
db.execute("INSERT INTO exercises VALUES(1,'ex_fixture')")
|
||||
db.execute("INSERT INTO sessions VALUES(1,'se_fixture','2026-01-01T00:00:00+00:00')")
|
||||
db.execute("INSERT INTO session_exercises VALUES(1,'sxe_fixture',1,1,0,?)", (equipment,))
|
||||
db.execute("PRAGMA user_version=7")
|
||||
db.commit(); db.close()
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
source, target, artifact = directory / 'source.db', directory / 'target.db', directory / 'equipment.json'
|
||||
make_db(source, 'leg_press'); make_db(target, None)
|
||||
subprocess.run([sys.executable, ROOT / 'tools/export_equipment_associations.py', artifact, '--database', source], check=True)
|
||||
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
|
||||
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] == 'leg_press'
|
||||
payload = json.loads(artifact.read_text()); payload['associations'][0] = {'session_id':'se_fixture','entry_id':'sxe_fixture','exercise_id':'ex_fixture','state':'cleared'}
|
||||
artifact.write_text(json.dumps(payload))
|
||||
subprocess.run([sys.executable, ROOT / 'tools/import_equipment_associations.py', artifact, '--database', target], check=True)
|
||||
assert sqlite3.connect(target).execute('SELECT equipment_id FROM session_exercises').fetchone()[0] is None
|
||||
print('PASS equipment association exchange')
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
127
tests/test_mobile_import_multi_occurrence.py
Normal file
127
tests/test_mobile_import_multi_occurrence.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""End-to-end V2 import regression: Walk, other movement, Walk again."""
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
||||
EQUIPMENT_IMPORTER = ROOT / "tools/import_equipment_associations.py"
|
||||
CATALOG_EXPORTER = ROOT / "tools/export_pc_catalog.py"
|
||||
MOBILE_EXPORTER = ROOT / "tools/export_pc_mobile.py"
|
||||
EQUIPMENT_EXPORTER = ROOT / "tools/export_equipment_associations.py"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER,exercise_row_id INTEGER,recording_mode TEXT,data_fields INTEGER,position INTEGER,load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);
|
||||
CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER UNIQUE,duration_seconds INTEGER,speed_kmh REAL,distance_km REAL);
|
||||
CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,session_row_id INTEGER,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);
|
||||
PRAGMA user_version=7;
|
||||
"""
|
||||
|
||||
def payload():
|
||||
catalog = [
|
||||
{"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0},
|
||||
{"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0},
|
||||
]
|
||||
entries = [
|
||||
{"entry_id":"sxe_walk_10","position":0,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":600}},
|
||||
{"entry_id":"sxe_push","position":1,"exercise_id":"ex_push","name":"Pompes","recording_mode":"sets","tracking_mode":"reps","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":"leg_press","sets":[{"reps":12,"weight_kg":20.0}]},
|
||||
{"entry_id":"sxe_walk_15","position":2,"exercise_id":"ex_walk","name":"Marche","recording_mode":"continuous","tracking_mode":"duration","data_fields":0,"load_mode":"none","rest_seconds":0,"equipment_id":None,"continuous":{"duration_seconds":900}},
|
||||
]
|
||||
return {"format":"trainlog-mobile-export","version":2,"generated_at":"2026-09-07T10:00:00+02:00","exercises":catalog,"sessions":[{"session_id":"se_multi","started_at":"2026-09-07T10:00:00+02:00","session_type":"training","exercises":entries}],"body_observations":[]}
|
||||
|
||||
def invoke(path, db):
|
||||
result = subprocess.run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], text=True, capture_output=True)
|
||||
if result.returncode: raise AssertionError(result.stdout + result.stderr)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def invoke_equipment(path, db):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(EQUIPMENT_IMPORTER), str(path), "--database", str(db)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode:
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
return result.stdout
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix="trainlog-multi-") as temp:
|
||||
root = Path(temp); db = root / "db.sqlite"; artifact = root / "mobile-v2.json"
|
||||
equipment = root / "equipment-v2.json"
|
||||
con = sqlite3.connect(db); con.executescript(SCHEMA); con.close()
|
||||
artifact.write_text(json.dumps(payload()), encoding="utf-8")
|
||||
assert "sessions_imported=1" in invoke(artifact, db)
|
||||
con = sqlite3.connect(db)
|
||||
rows = con.execute("SELECT e.exercise_id,se.entry_id,se.position,ca.duration_seconds FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id ORDER BY se.position").fetchall()
|
||||
assert rows == [("ex_walk","sxe_walk_10",0,600),("ex_push","sxe_push",1,None),("ex_walk","sxe_walk_15",2,900)], rows
|
||||
assert con.execute("SELECT weight_kg FROM performed_sets").fetchone()[0] == 20.0
|
||||
con.close()
|
||||
|
||||
equipment.write_text(json.dumps({
|
||||
"format": "trainlog-equipment-associations",
|
||||
"version": 2,
|
||||
"generated_at": "2026-09-07T10:00:00+02:00",
|
||||
"associations": [
|
||||
{"session_id": "se_multi", "entry_id": "sxe_walk_10", "exercise_id": "ex_walk", "state": "set", "equipment_id": "treadmill"},
|
||||
{"session_id": "se_multi", "entry_id": "sxe_push", "exercise_id": "ex_push", "state": "set", "equipment_id": "leg_press"},
|
||||
{"session_id": "se_multi", "entry_id": "sxe_walk_15", "exercise_id": "ex_walk", "state": "cleared"},
|
||||
],
|
||||
}), encoding="utf-8")
|
||||
|
||||
# Regression for the real failure: the historic TUI invocation passed
|
||||
# only the artifact, while the importer requires the target database.
|
||||
rejected = subprocess.run(
|
||||
[sys.executable, str(EQUIPMENT_IMPORTER), str(equipment)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert rejected.returncode != 0
|
||||
assert "the following arguments are required: --database" in rejected.stderr
|
||||
|
||||
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
||||
con = sqlite3.connect(db)
|
||||
rows = con.execute(
|
||||
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
|
||||
).fetchall()
|
||||
assert rows == [
|
||||
("sxe_walk_10", "treadmill"),
|
||||
("sxe_push", "leg_press"),
|
||||
("sxe_walk_15", None),
|
||||
], rows
|
||||
con.close()
|
||||
|
||||
assert "sessions_reconciled=1" in invoke(artifact, db)
|
||||
assert "EQUIPMENT_ASSOCIATIONS_IMPORT=PASS" in invoke_equipment(equipment, db)
|
||||
con = sqlite3.connect(db)
|
||||
assert con.execute("SELECT count(*) FROM session_exercises").fetchone()[0] == 3
|
||||
assert con.execute(
|
||||
"SELECT entry_id,equipment_id FROM session_exercises ORDER BY position"
|
||||
).fetchall() == rows
|
||||
con.close()
|
||||
|
||||
catalog_output = root / "pc-catalog.json"
|
||||
mobile_output = root / "pc-mobile-v2.json"
|
||||
equipment_output = root / "pc-equipment-v2.json"
|
||||
for tool, output, marker in (
|
||||
(CATALOG_EXPORTER, catalog_output, "PC_CATALOG_EXPORT=PASS"),
|
||||
(MOBILE_EXPORTER, mobile_output, "PC_MOBILE_EXPORT=PASS"),
|
||||
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
|
||||
):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(tool), str(output), "--database", str(db)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert marker in result.stdout
|
||||
print("PASS mobile_import_multi_occurrence")
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
45
tools/export_equipment_associations.py
Normal file
45
tools/export_equipment_associations.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export stable desktop session/exercise equipment associations."""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path,
|
||||
default=Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db")
|
||||
args = parser.parse_args()
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
||||
raise ValueError("schema desktop v7 requis")
|
||||
rows = connection.execute(
|
||||
"SELECT s.session_id,se.entry_id,e.exercise_id,se.equipment_id FROM session_exercises se "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"ORDER BY s.started_at,s.id,se.position").fetchall()
|
||||
payload = {"format": "trainlog-equipment-associations", "version": 2,
|
||||
"generated_at": datetime.now().astimezone().isoformat(), "associations": []}
|
||||
for session_id, entry_id, exercise_id, equipment_id in rows:
|
||||
item = {"session_id": session_id, "entry_id": entry_id, "exercise_id": exercise_id,
|
||||
"state": "set" if equipment_id is not None else "cleared"}
|
||||
if equipment_id is not None:
|
||||
item["equipment_id"] = equipment_id
|
||||
payload["associations"].append(item)
|
||||
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
print("EQUIPMENT_ASSOCIATIONS_EXPORT=PASS")
|
||||
print(f"associations={len(rows)}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"EQUIPMENT_ASSOCIATIONS_EXPORT=FAIL {error}")
|
||||
raise SystemExit(1)
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
66
tools/export_pc_mobile.py
Normal file
66
tools/export_pc_mobile.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Publish desktop sessions through the occurrence-aware mobile export V2."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def default_database():
|
||||
return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog" / "trainlog.db"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, default=default_database())
|
||||
args = parser.parse_args()
|
||||
con = sqlite3.connect(args.database)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] != 7:
|
||||
raise ValueError("schema desktop v7 requis")
|
||||
root = {"format": "trainlog-mobile-export", "version": 2,
|
||||
"generated_at": datetime.now().astimezone().isoformat(),
|
||||
"exercises": [], "sessions": [], "body_observations": []}
|
||||
for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"):
|
||||
root["exercises"].append(dict(row))
|
||||
for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"):
|
||||
payload = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
||||
payload["exercises"] = []
|
||||
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
||||
# retain their stable entry_id but do not duplicate that field.
|
||||
sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position"
|
||||
for entry in con.execute(sql, (session["id"],)):
|
||||
item = {"entry_id": entry["entry_id"], "position": entry["position"],
|
||||
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
||||
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
||||
"data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0,
|
||||
"equipment_id": entry["equipment_id"]}
|
||||
if entry["recording_mode"] == "continuous":
|
||||
activity = con.execute("SELECT duration_seconds,speed_kmh,distance_km FROM continuous_activity WHERE session_exercise_row_id=?", (entry["id"],)).fetchone()
|
||||
if activity is None: raise ValueError("activité continue absente")
|
||||
item["continuous"] = {key: activity[key] for key in activity.keys() if activity[key] is not None}
|
||||
else:
|
||||
item["sets"] = []
|
||||
for value in con.execute("SELECT reps,duration_seconds,weight_kg FROM performed_sets WHERE session_exercise_row_id=? ORDER BY position", (entry["id"],)):
|
||||
metric = "reps" if entry["tracking_mode"] == "reps" else "duration_seconds"
|
||||
set_value = {metric: value[metric]}
|
||||
if value["weight_kg"] is not None: set_value["weight_kg"] = value["weight_kg"]
|
||||
item["sets"].append(set_value)
|
||||
payload["exercises"].append(item)
|
||||
root["sessions"].append(payload)
|
||||
args.output.write_text(json.dumps(root, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
print("PC_MOBILE_EXPORT=PASS")
|
||||
print("sessions=" + str(len(root["sessions"])))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try: main()
|
||||
except Exception as exc:
|
||||
print("PC_MOBILE_EXPORT=FAIL " + str(exc))
|
||||
raise SystemExit(1)
|
||||
72
tools/generate_equipment_catalog.py
Normal file
72
tools/generate_equipment_catalog.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate the desktop C catalogue from the one versioned JSON source."""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
src, out = sys.argv[1:]
|
||||
root = json.load(open(src, encoding="utf-8"))
|
||||
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
|
||||
raise SystemExit("unsupported equipment catalogue")
|
||||
items = root.get("equipment")
|
||||
if not isinstance(items, list):
|
||||
raise SystemExit("equipment must be a list")
|
||||
ids = set()
|
||||
for item in items:
|
||||
required = ("id", "label_name", "display_name", "aliases", "type", "load_semantics")
|
||||
if not all(key in item for key in required) or not isinstance(item["aliases"], list):
|
||||
raise SystemExit("invalid equipment entry")
|
||||
if not item["id"] or item["id"] in ids:
|
||||
raise SystemExit("empty or duplicate equipment id")
|
||||
ids.add(item["id"])
|
||||
if item["load_semantics"] not in {"external", "assistance", "bodyweight", "cardio"}:
|
||||
raise SystemExit("invalid load semantics")
|
||||
for relation in root.get("exercise_equipment", []):
|
||||
if relation.get("equipment_id") not in ids:
|
||||
raise SystemExit("relation references unknown equipment")
|
||||
|
||||
def c(value):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write('#include "trainlog/equipment_catalog.h"\n#include <string.h>\n\n')
|
||||
f.write('static const TrainlogEquipment entries[] = {\n')
|
||||
for item in items:
|
||||
f.write(' {%s, %s, %s, %s, %s, %s},\n' % (
|
||||
c(item['id']), c(item['label_name']), c(item['display_name']),
|
||||
c(item['type']), c(item['load_semantics']), c('\n'.join(item['aliases']))))
|
||||
f.write('};\nstatic const TrainlogExerciseEquipmentRelation relations[] = {\n')
|
||||
for rel in root.get('exercise_equipment', []):
|
||||
f.write(' {%s, %s, %s},\n' % (c(rel['exercise_id']), c(rel['equipment_id']), c(rel['load_semantics'])))
|
||||
f.write('};\n')
|
||||
f.write(r'''
|
||||
static int folded_contains(const char *haystack, const char *needle) {
|
||||
size_t i, j;
|
||||
if (needle[0] == '\0') return 1;
|
||||
for (i = 0; haystack[i] != '\0'; ++i) {
|
||||
for (j = 0; needle[j] != '\0'; ++j) {
|
||||
unsigned char a = (unsigned char)haystack[i + j];
|
||||
unsigned char b = (unsigned char)needle[j];
|
||||
if (a >= 'A' && a <= 'Z') a = (unsigned char)(a + ('a' - 'A'));
|
||||
if (b >= 'A' && b <= 'Z') b = (unsigned char)(b + ('a' - 'A'));
|
||||
if (a == '\0' || a != b) break;
|
||||
}
|
||||
if (needle[j] == '\0') return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
size_t trainlog_equipment_catalog_count(void) { return sizeof(entries) / sizeof(entries[0]); }
|
||||
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t i) { return i < trainlog_equipment_catalog_count() ? &entries[i] : NULL; }
|
||||
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *id) {
|
||||
size_t i; if (id == NULL) return NULL;
|
||||
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (strcmp(entries[i].equipment_id, id) == 0) return &entries[i];
|
||||
return NULL;
|
||||
}
|
||||
size_t trainlog_equipment_catalog_search(const char *q, const TrainlogEquipment **out, size_t cap) {
|
||||
size_t i, found = 0; if (q == NULL || out == NULL) return 0;
|
||||
for (i = 0; i < trainlog_equipment_catalog_count(); ++i) if (folded_contains(entries[i].label_name, q) || folded_contains(entries[i].display_name, q) || folded_contains(entries[i].aliases, q)) { if (found < cap) out[found] = &entries[i]; ++found; }
|
||||
return found;
|
||||
}
|
||||
size_t trainlog_equipment_catalog_relation_count(void) { return sizeof(relations) / sizeof(relations[0]); }
|
||||
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t i) { return i < trainlog_equipment_catalog_relation_count() ? &relations[i] : NULL; }
|
||||
''')
|
||||
80
tools/import_equipment_associations.py
Normal file
80
tools/import_equipment_associations.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the versioned equipment companion artifact to a desktop database."""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
FORMAT = "trainlog-equipment-associations"
|
||||
VERSION = 2
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def load_catalog(path):
|
||||
root = json.loads(path.read_text(encoding="utf-8"))
|
||||
if root.get("format") != "trainlog-equipment-catalog" or root.get("version") != 1:
|
||||
fail("catalogue équipement non supporté")
|
||||
return {item["id"] for item in root["equipment"]}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("artifact", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
parser.add_argument("--catalog", type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "catalog/equipment-v1.json")
|
||||
args = parser.parse_args()
|
||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
if payload.get("format") != FORMAT or payload.get("version") != VERSION:
|
||||
fail("extension équipement non supportée")
|
||||
if set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||
fail("clés extension équipement invalides")
|
||||
known = load_catalog(args.catalog)
|
||||
connection = sqlite3.connect(args.database)
|
||||
try:
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] != 7:
|
||||
fail("schema desktop v7 requis")
|
||||
seen = set()
|
||||
with connection:
|
||||
for item in payload["associations"]:
|
||||
if not isinstance(item, dict):
|
||||
fail("association invalide")
|
||||
session_id, entry_id, exercise_id, state = (item.get(k) for k in ("session_id", "entry_id", "exercise_id", "state"))
|
||||
key = (session_id, entry_id)
|
||||
if not isinstance(session_id, str) or not session_id or not isinstance(entry_id, str) or not entry_id or not isinstance(exercise_id, str) or not exercise_id or key in seen:
|
||||
fail("identité association invalide ou dupliquée")
|
||||
seen.add(key)
|
||||
if state == "set":
|
||||
if set(item) != {"session_id", "entry_id", "exercise_id", "state", "equipment_id"}:
|
||||
fail("association set invalide")
|
||||
equipment_id = item["equipment_id"]
|
||||
if not isinstance(equipment_id, str) or equipment_id not in known:
|
||||
fail(f"équipement inconnu: {equipment_id}")
|
||||
elif state == "cleared":
|
||||
if set(item) != {"session_id", "entry_id", "exercise_id", "state"}:
|
||||
fail("association cleared invalide")
|
||||
equipment_id = None
|
||||
else:
|
||||
fail("état association invalide")
|
||||
cursor = connection.execute(
|
||||
"UPDATE session_exercises SET equipment_id=? WHERE id=("
|
||||
"SELECT se.id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
|
||||
"WHERE s.session_id=? AND se.entry_id=?)",
|
||||
(equipment_id, session_id, entry_id))
|
||||
if cursor.rowcount != 1:
|
||||
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
||||
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
|
||||
print(f"associations={len(seen)}")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(f"EQUIPMENT_ASSOCIATIONS_IMPORT=FAIL {error}")
|
||||
raise SystemExit(1)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
31
tui/include/trainlog/equipment_catalog.h
Normal file
31
tui/include/trainlog/equipment_catalog.h
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#ifndef TRAINLOG_EQUIPMENT_CATALOG_H
|
||||
#define TRAINLOG_EQUIPMENT_CATALOG_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* CONTRACT: data is generated at build time from catalog/equipment-v1.json.
|
||||
* Callers borrow returned strings for process lifetime and must not free them. */
|
||||
typedef struct TrainlogEquipment {
|
||||
const char *equipment_id;
|
||||
const char *label_name;
|
||||
const char *display_name;
|
||||
const char *equipment_type;
|
||||
const char *load_semantics;
|
||||
const char *aliases; /* newline-separated canonical aliases */
|
||||
} TrainlogEquipment;
|
||||
|
||||
typedef struct TrainlogExerciseEquipmentRelation {
|
||||
const char *exercise_id;
|
||||
const char *equipment_id;
|
||||
const char *load_semantics;
|
||||
} TrainlogExerciseEquipmentRelation;
|
||||
|
||||
size_t trainlog_equipment_catalog_count(void);
|
||||
const TrainlogEquipment *trainlog_equipment_catalog_at(size_t index);
|
||||
const TrainlogEquipment *trainlog_equipment_catalog_lookup(const char *equipment_id);
|
||||
/* Search is deterministic and ASCII-case-insensitive over label, display and aliases. */
|
||||
size_t trainlog_equipment_catalog_search(const char *query, const TrainlogEquipment **output, size_t capacity);
|
||||
size_t trainlog_equipment_catalog_relation_count(void);
|
||||
const TrainlogExerciseEquipmentRelation *trainlog_equipment_catalog_relation_at(size_t index);
|
||||
|
||||
#endif
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/duration.h"
|
||||
#include "trainlog/equipment_catalog.h"
|
||||
#include "trainlog/id.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
141
tui/src/sync.c
141
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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
24
tui/tests/test_equipment_catalog.c
Normal file
24
tui/tests/test_equipment_catalog.c
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#include <assert.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "trainlog/equipment_catalog.h"
|
||||
|
||||
int main(void) {
|
||||
const TrainlogEquipment *matches[8];
|
||||
const TrainlogEquipment *machine;
|
||||
size_t count;
|
||||
|
||||
assert(trainlog_equipment_catalog_count() == 38U);
|
||||
machine = trainlog_equipment_catalog_lookup("assisted_dip_chin_machine");
|
||||
assert(machine != NULL);
|
||||
assert(strcmp(machine->load_semantics, "assistance") == 0);
|
||||
count = trainlog_equipment_catalog_search("TIRAGE", matches, 8U);
|
||||
assert(count >= 6U);
|
||||
count = trainlog_equipment_catalog_search("ischio", matches, 8U);
|
||||
assert(count >= 2U);
|
||||
assert(trainlog_equipment_catalog_relation_count() == 2U);
|
||||
assert(strcmp(trainlog_equipment_catalog_relation_at(0U)->equipment_id,
|
||||
"assisted_dip_chin_machine") == 0);
|
||||
assert(trainlog_equipment_catalog_lookup("missing") == NULL);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -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 <stdbool.h>
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue